From eae362aa7bcc6a8c5f39e901c0f4e5aa4073b14c Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 31 Oct 2026 13:18:22 +0100 Subject: [PATCH 1/4] chore: carbon ads Co-authored-by: Claudio Wunder Co-authored-by: Brian Muenzenmeyer Co-authored-by: Matteo Collina --- .changeset/smart-lilies-invent.md | 5 + apps/site/components/CarbonAds/Sidebar.tsx | 50 ++++++++ apps/site/components/CarbonAds/TopBar.tsx | 42 +++++++ .../components/CarbonAds/index.module.css | 112 ++++++++++++++++++ apps/site/components/MetaBar.tsx | 2 + apps/site/hooks/useCarbonAd.ts | 90 ++++++++++++++ apps/site/layouts/About.tsx | 3 + apps/site/layouts/ArticlePage.tsx | 3 + apps/site/layouts/Blog.tsx | 3 + apps/site/layouts/Post.tsx | 3 + apps/site/next.constants.mjs | 11 ++ apps/site/turbo.json | 4 + .../MetaBar/__tests__/index.test.jsx | 23 ++++ .../src/Containers/MetaBar/index.tsx | 6 +- 14 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 .changeset/smart-lilies-invent.md create mode 100644 apps/site/components/CarbonAds/Sidebar.tsx create mode 100644 apps/site/components/CarbonAds/TopBar.tsx create mode 100644 apps/site/components/CarbonAds/index.module.css create mode 100644 apps/site/hooks/useCarbonAd.ts diff --git a/.changeset/smart-lilies-invent.md b/.changeset/smart-lilies-invent.md new file mode 100644 index 0000000000000..df5a9032cd185 --- /dev/null +++ b/.changeset/smart-lilies-invent.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +Adds a footer slot to the metabar diff --git a/apps/site/components/CarbonAds/Sidebar.tsx b/apps/site/components/CarbonAds/Sidebar.tsx new file mode 100644 index 0000000000000..0310892cf850f --- /dev/null +++ b/apps/site/components/CarbonAds/Sidebar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import classNames from 'classnames'; + +import useCarbonAd, { adColors } from '#site/hooks/useCarbonAd'; + +import type { FC } from 'react'; + +import styles from './index.module.css'; + +const CarbonSidebar: FC = () => { + const { ad, ref } = useCarbonAd('CWBI6KJU', '(min-width: 1280px)'); + + if (!ad) { + return null; + } + + return ( + + ); +}; + +export default CarbonSidebar; diff --git a/apps/site/components/CarbonAds/TopBar.tsx b/apps/site/components/CarbonAds/TopBar.tsx new file mode 100644 index 0000000000000..4f6999eddad0b --- /dev/null +++ b/apps/site/components/CarbonAds/TopBar.tsx @@ -0,0 +1,42 @@ +'use client'; + +import useCarbonAd, { adColors } from '#site/hooks/useCarbonAd'; + +import type { FC } from 'react'; + +import styles from './index.module.css'; + +const CarbonTopBar: FC = () => { + const { ad, ref } = useCarbonAd('CWBI6KJY'); + + if (!ad) { + return null; + } + + return ( + + ); +}; + +export default CarbonTopBar; diff --git a/apps/site/components/CarbonAds/index.module.css b/apps/site/components/CarbonAds/index.module.css new file mode 100644 index 0000000000000..c283ed0ef7421 --- /dev/null +++ b/apps/site/components/CarbonAds/index.module.css @@ -0,0 +1,112 @@ +@reference "../../styles/index.css"; + +.topBar, +.sideBar { + @apply relative + bg-(--ad-bg) + text-(color:--ad-text) + dark:bg-(--ad-bg-dark) + dark:text-(color:--ad-text-dark); + + & a { + @apply text-inherit + no-underline; + } +} + +.badge { + @apply shrink-0 + rounded-sm + border + border-current + px-1.5 + text-xs + leading-4 + opacity-70 + hover:opacity-100; +} + +.topBar { + @apply mb-4 + flex + items-center + gap-3 + rounded-md + px-4 + py-2 + text-sm; +} + +.topBarLink { + @apply flex + flex-1 + items-center + gap-3; +} + +.logo { + @apply size-8 + shrink-0 + rounded-sm; +} + +.cta { + @apply hidden + shrink-0 + rounded-md + bg-(--ad-cta-bg) + px-3 + py-1 + text-xs + font-semibold + whitespace-nowrap + text-(color:--ad-cta-text) + sm:block + dark:bg-(--ad-cta-bg-dark) + dark:text-(color:--ad-cta-text-dark); +} + +.sideBar { + @apply w-44 + overflow-hidden + rounded-lg; +} + +.sideBarLink { + @apply flex + flex-col; +} + +.image { + @apply aspect-square + w-full + object-cover; +} + +.sideBarBody { + @apply flex + flex-col + items-start + gap-1 + p-3.5 + text-xs; +} + +.sideBarCta { + @apply mt-2 + rounded-full + border + border-current + px-3 + py-1 + font-semibold; +} + +.overlay { + @apply absolute + top-2 + right-2 + border-transparent + bg-neutral-950/50 + text-white; +} diff --git a/apps/site/components/MetaBar.tsx b/apps/site/components/MetaBar.tsx index 4d88cabd0ba9f..788f5fc55a649 100644 --- a/apps/site/components/MetaBar.tsx +++ b/apps/site/components/MetaBar.tsx @@ -6,6 +6,7 @@ import { defaultLocale } from '@node-core/website-i18n'; import { useFormatter, useLocale, useTranslations } from 'next-intl'; import AvatarGroup from '#site/components/AvatarGroup'; +import CarbonSidebar from '#site/components/CarbonAds/Sidebar'; import Link from '#site/components/Link'; import useClientContext from '#site/hooks/useClientContext'; import useMediaQuery from '#site/hooks/useMediaQuery'; @@ -74,6 +75,7 @@ const MetaBar: FC = () => { ), }} headings={{ items: headings }} + footer={} /> ); }; diff --git a/apps/site/hooks/useCarbonAd.ts b/apps/site/hooks/useCarbonAd.ts new file mode 100644 index 0000000000000..83d5f6052337a --- /dev/null +++ b/apps/site/hooks/useCarbonAd.ts @@ -0,0 +1,90 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import useMediaQuery from '#site/hooks/useMediaQuery'; +import { + CARBON_ADS_COUNT_IMPRESSIONS, + CARBON_ADS_URL, +} from '#site/next.constants.mjs'; + +import type { CSSProperties } from 'react'; + +export type CarbonAd = Record; + +export const adColors = (ad: CarbonAd) => + ({ + '--ad-bg': ad.backgroundColor, + '--ad-bg-dark': ad.backgroundColorDark ?? ad.backgroundColor, + '--ad-text': ad.textColor, + '--ad-text-dark': ad.textColorDark ?? ad.textColor, + '--ad-cta-bg': ad.ctaBackgroundColor, + '--ad-cta-bg-dark': ad.ctaBackgroundColorDark ?? ad.ctaBackgroundColor, + '--ad-cta-text': ad.ctaTextColor, + '--ad-cta-text-dark': ad.ctaTextColorDark ?? ad.ctaTextColor, + }) as CSSProperties; + +const useCarbonAd = (zone: string, media?: string) => { + const [ad, setAd] = useState(null); + const ref = useRef(null); + // Layouts persist across navigations, so refetch per page + const pathname = usePathname(); + const matches = useMediaQuery(media ?? 'all'); + + useEffect(() => { + if (!matches) { + return; + } + + const controller = new AbortController(); + const url = new URL(`${zone}.json`, CARBON_ADS_URL); + + if (!CARBON_ADS_COUNT_IMPRESSIONS) { + url.searchParams.set('ignore', 'yes'); + } + + fetch(url, { signal: controller.signal }) + .then(response => (response.ok ? response.json() : null)) + .then(data => data?.ads?.[0]?.statlink && setAd(data.ads[0])) + .catch(() => {}); + + return () => { + controller.abort(); + setAd(null); + }; + }, [zone, matches, pathname]); + + useEffect(() => { + if (!ad?.statview || !ref.current) { + return; + } + + let timer: ReturnType; + + const observer = new IntersectionObserver( + entries => { + clearTimeout(timer); + + if (entries.at(-1)!.intersectionRatio >= 0.5) { + timer = setTimeout(() => { + new Image().src = ad.statview; + observer.disconnect(); + }, 1000); + } + }, + { threshold: 0.5 } + ); + + observer.observe(ref.current); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [ad]); + + return { ad, ref }; +}; + +export default useCarbonAd; diff --git a/apps/site/layouts/About.tsx b/apps/site/layouts/About.tsx index 4c53a24799298..d80b889f8cc84 100644 --- a/apps/site/layouts/About.tsx +++ b/apps/site/layouts/About.tsx @@ -1,6 +1,7 @@ import Article from '@node-core/ui-components/Containers/Article'; import Breadcrumbs from '#site/components/Breadcrumbs'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import MetaBar from '#site/components/MetaBar'; import NavBar from '#site/components/NavBar'; @@ -17,6 +18,8 @@ const AboutLayout: FC = ({ children }) => (
+ + {children}
diff --git a/apps/site/layouts/ArticlePage.tsx b/apps/site/layouts/ArticlePage.tsx index ebca2366de34a..63f58de69d460 100644 --- a/apps/site/layouts/ArticlePage.tsx +++ b/apps/site/layouts/ArticlePage.tsx @@ -1,5 +1,6 @@ import Article from '@node-core/ui-components/Containers/Article'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import MetaBar from '#site/components/MetaBar'; import NavBar from '#site/components/NavBar'; @@ -16,6 +17,8 @@ const ArticlePageLayout: FC = ({ children }) => (
+ + {children}
diff --git a/apps/site/layouts/Blog.tsx b/apps/site/layouts/Blog.tsx index 7cf0755dd23a0..cc00cad2b0f3f 100644 --- a/apps/site/layouts/Blog.tsx +++ b/apps/site/layouts/Blog.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { getClientContext } from '#site/client-context'; import BlogHeader from '#site/components/Blog/BlogHeader'; import BlogCategories from '#site/components/BlogCategories'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import NavBar from '#site/components/NavBar'; import { getBlogData } from '#site/util/blog'; @@ -55,6 +56,8 @@ const BlogLayout: FC = () => {
+ + = ({ children }) => {
{type === 'vulnerability' && } + +

{frontmatter.title}

diff --git a/apps/site/next.constants.mjs b/apps/site/next.constants.mjs index b9509bb4c19f3..ff16d1ed200da 100644 --- a/apps/site/next.constants.mjs +++ b/apps/site/next.constants.mjs @@ -172,3 +172,14 @@ export const ORAMA_DB_URLS = { [LEARN_URL.slice(0, -1)]: 'https://nodejs.org/learn/orama-db.json', [`${DOCS_URL}latest/api`]: 'https://beta.docs.nodejs.org/orama-db.json', }; + +/** + * The Carbon Ads JSON API endpoint (suffixed with `{zone}.json`) + */ +export const CARBON_ADS_URL = 'https://srv.carbonads.net/ads/'; + +/** + * Whether Carbon Ads should count impressions (production only) + */ +export const CARBON_ADS_COUNT_IMPRESSIONS = + process.env.NEXT_PUBLIC_CARBON_ADS_PRODUCTION === 'true'; diff --git a/apps/site/turbo.json b/apps/site/turbo.json index bcb3469ba50dc..b26f340ed07df 100644 --- a/apps/site/turbo.json +++ b/apps/site/turbo.json @@ -22,6 +22,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" @@ -53,6 +54,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS", @@ -72,6 +74,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" @@ -96,6 +99,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" diff --git a/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx b/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx index 6460c2fdf125d..ed05df5dd8c69 100644 --- a/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx +++ b/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx @@ -60,4 +60,27 @@ describe('MetaBar', () => { const h6Element = queryByText('Heading Level 6'); assert.ok(!h6Element?.ownerDocument); }); + + it('renders an optional footer after the metadata list', () => { + const { container, getByText } = render( + Footer content

} + /> + ); + + const footer = getByText('Footer content'); + + assert.equal(container.querySelector('dl').contains(footer), false); + assert.equal(container.querySelector('aside').contains(footer), true); + }); + + it('renders no footer by default', () => { + const { container } = render( + + ); + + assert.equal(container.querySelector('aside').children.length, 1); + }); }); diff --git a/packages/ui-components/src/Containers/MetaBar/index.tsx b/packages/ui-components/src/Containers/MetaBar/index.tsx index 8f73c84127837..d66304e694a3b 100644 --- a/packages/ui-components/src/Containers/MetaBar/index.tsx +++ b/packages/ui-components/src/Containers/MetaBar/index.tsx @@ -4,7 +4,7 @@ import { Fragment, useMemo } from 'react'; import { CODE_LIKE_TYPES } from '#ui/constants'; import type { Heading, LinkLike } from '#ui/types'; -import type { FC, HTMLAttributes } from 'react'; +import type { FC, HTMLAttributes, ReactNode } from 'react'; import styles from './index.module.css'; @@ -16,6 +16,7 @@ type MetaBarProps = { }; as?: LinkLike; heading: string; + footer?: ReactNode; } & HTMLAttributes; const MetaBar: FC = ({ @@ -23,6 +24,7 @@ const MetaBar: FC = ({ headings, as: Component = 'a', heading, + footer, ...props }) => { // The default depth of headings to display in the table of contents. @@ -74,6 +76,8 @@ const MetaBar: FC = ({ )} + + {footer} ); }; From c5fa89363476ffee1eeeafc2a43343d71ee2b8a7 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 31 Oct 2026 13:30:56 +0100 Subject: [PATCH 2/4] fixup! --- apps/site/components/CarbonAds/index.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/site/components/CarbonAds/index.module.css b/apps/site/components/CarbonAds/index.module.css index c283ed0ef7421..2711d6165e9fb 100644 --- a/apps/site/components/CarbonAds/index.module.css +++ b/apps/site/components/CarbonAds/index.module.css @@ -68,6 +68,7 @@ .sideBar { @apply w-44 + shrink-0 overflow-hidden rounded-lg; } From 35f07be0bd6775d51b00ba0f8a18ac99330937f1 Mon Sep 17 00:00:00 2001 From: bmuenzenmeyer Date: Mon, 2 Nov 2026 19:19:47 -0600 Subject: [PATCH 3/4] chore: re-add docs --- docs/carbon-ads.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/carbon-ads.md diff --git a/docs/carbon-ads.md b/docs/carbon-ads.md new file mode 100644 index 0000000000000..6c6bdf09d2d33 --- /dev/null +++ b/docs/carbon-ads.md @@ -0,0 +1,58 @@ +# Carbon Ads + +The Website shows [Carbon Ads](https://www.carbonads.net/) through the OpenJS Foundation's Carbon Ads project for Node.js. Ads are rendered from Carbon's JSON API, directly in the visitor's browser, without any third-party script. + +## Placements + +| Format | Zone key | Where | +| ------- | ---------- | ----------------------------------------------------------- | +| Banner | `CWBI6KJY` | Full width, above page content, on the layouts listed below | +| Sidebar | `CWBI6KJU` | Below the table of contents, from the `xl` breakpoint | + +| Layout | Banner | Sidebar | +| ------------------------ | ------ | ------------------------- | +| `about`, `article` | Yes | From the `xl` breakpoint | +| `blog-post` | Yes | From the `xl` breakpoint | +| `blog-category` | Yes | No (no right-hand column) | +| `download` | **No** | **No** | +| `download-archive` | **No** | **No** | +| `home`, error/404 pages | **No** | **No** | +| `page` (fallback layout) | No | No | + +The homepage never renders any ad markup, reserves no space and makes no ad requests. Layouts opt in explicitly through `` before their main content. The Sidebar is rendered inside `` (`apps/site/components/MetaBar.tsx`), so every layout that uses it shows the Sidebar. + +On narrower screens the Sidebar is neither shown nor requested (the Top Bar covers those views). Its breakpoint is hard-coded as `(min-width: 1280px)` in `apps/site/components/CarbonAds/Sidebar.tsx` and must match the layout grids. Resizing the window back above the breakpoint requests a new Sidebar ad, which records another impression. + +Learn (`/learn`) and the API docs (`/api`, `/docs`) are separate applications and are not affected by this integration. + +## How it works + +- `apps/site/hooks/useCarbonAd.ts` requests the ad for the current page view and sends its view beacon. A page view changes with the pathname or locale (not with hashes or query strings), so a revisited page always gets a fresh ad. +- `apps/site/components/CarbonAds/TopBar.tsx` and `apps/site/components/CarbonAds/Sidebar.tsx` render each format. No space is reserved while loading; when there is no ad (empty response, invalid data, network error or ad blocker) nothing is shown. +- The ad request itself records the impression. The `statview` beacon is sent once per page view after at least 50% of the ad was visible for one continuous second. Clicks go through `statlink`. + +## Staging and production + +By default, every request is sent with `ignore=yes`, which returns a real ad **without counting the impression**. This applies to local development, tests and all preview deployments (which are production builds too). + +Impressions are only counted when the build has: + +```bash +NEXT_PUBLIC_CARBON_ADS_PRODUCTION=true +``` + +As with any `NEXT_PUBLIC_` variable, this value is embedded at build time, so it must be set in the environment of the **production deployment build only** (e.g. the Vercel "Production" environment, or the Cloudflare production build), never for previews. Changing it requires a new build. It is listed in `apps/site/turbo.json` so that it is part of the build cache key; without that, a cached preview build could be reused for production (or vice versa). + +There is no Content Security Policy on the Website at the moment. If one is added, it must allow `srv.carbonads.net` (ad requests), `srv.buysellads.com` (view beacons) and the creative image host (e.g. `cdn4.buysellads.net`). + +### Launch sequence + +1. Deploy the integration to a staging/preview URL (impressions are not counted there). +2. Send the staging URL to Carbon for review. They serve a test creative and check requests, view beacons and click links. +3. Set `NEXT_PUBLIC_CARBON_ADS_PRODUCTION=true` for the production build and deploy. +4. For the first seven days Carbon counts traffic but serves no ads: responses are empty and the slots render nothing. +5. Paid ads then start appearing in the same slots, without code changes. + +## Testing + +There are no unit tests for the integration yet. When testing manually, avoid clicking real ads repeatedly; prefer intercepting `https://srv.carbonads.net/ads/*.json` with fixtures (e.g. in Playwright or the browser's developer tools). From da292f125712a466a1d6eff9669ead573ef9e8de Mon Sep 17 00:00:00 2001 From: bmuenzenmeyer Date: Mon, 2 Nov 2026 19:32:04 -0600 Subject: [PATCH 4/4] chore: selectively re-add tests --- .../site/hooks/__tests__/useCarbonAd.test.jsx | 127 ++++++++++++++++++ apps/site/tests/e2e/carbon-ads.spec.ts | 101 ++++++++++++++ docs/carbon-ads.md | 2 +- 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 apps/site/hooks/__tests__/useCarbonAd.test.jsx create mode 100644 apps/site/tests/e2e/carbon-ads.spec.ts diff --git a/apps/site/hooks/__tests__/useCarbonAd.test.jsx b/apps/site/hooks/__tests__/useCarbonAd.test.jsx new file mode 100644 index 0000000000000..b4e1f31ea66c0 --- /dev/null +++ b/apps/site/hooks/__tests__/useCarbonAd.test.jsx @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it, mock } from 'node:test'; + +import { act, render } from '@testing-library/react'; + +mock.module('next/navigation', { + namedExports: { usePathname: () => '/en/about' }, +}); + +const { default: useCarbonAd } = await import('#site/hooks/useCarbonAd'); + +const ad = { + company: 'Company', + statlink: 'https://srv.buysellads.com/ads/click/x/1', + statview: 'https://srv.buysellads.com/ads/view/x/1', +}; + +let requests; +let observers; +let beacons; + +class FakeIntersectionObserver { + constructor(callback) { + this.callback = callback; + observers.push(this); + } + + observe() {} + + disconnect() { + this.disconnected = true; + } +} + +const intersect = ratio => + act(() => { + for (const observer of observers.filter(o => !o.disconnected)) { + observer.callback([{ intersectionRatio: ratio }]); + } + }); + +// Lets pending fetch promises and their React state updates settle +const flush = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); + +const Probe = () => { + const { ad, ref } = useCarbonAd('CWBI6KJY'); + + return ad ?
{ad.company}
: null; +}; + +const renderAd = async t => { + render(); + await flush(); + + t.mock.timers.enable({ apis: ['setTimeout'] }); +}; + +beforeEach(() => { + requests = []; + observers = []; + beacons = []; + + mock.method(globalThis, 'fetch', async url => { + requests.push(new URL(url)); + + return { ok: true, json: async () => ({ ads: [ad] }) }; + }); + + window.matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + + globalThis.IntersectionObserver = FakeIntersectionObserver; + + globalThis.Image = class { + set src(value) { + beacons.push(value); + } + }; +}); + +describe('useCarbonAd', () => { + it('does not count impressions outside production', async () => { + render(); + await flush(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].searchParams.get('ignore'), 'yes'); + }); + + it('sends the view beacon after the ad was at least 50% visible for one second', async t => { + await renderAd(t); + + intersect(0.5); + t.mock.timers.tick(999); + + assert.deepEqual(beacons, []); + + t.mock.timers.tick(1); + + assert.deepEqual(beacons, [ad.statview]); + }); + + it('does not send the view beacon when the ad stops being visible within one second', async t => { + await renderAd(t); + + intersect(1); + t.mock.timers.tick(500); + intersect(0.4); + t.mock.timers.tick(5000); + + assert.deepEqual(beacons, []); + }); + + it('sends the view beacon only once per ad', async t => { + await renderAd(t); + + for (let i = 0; i < 3; i++) { + intersect(1); + t.mock.timers.tick(1000); + } + + assert.equal(beacons.length, 1); + }); +}); diff --git a/apps/site/tests/e2e/carbon-ads.spec.ts b/apps/site/tests/e2e/carbon-ads.spec.ts new file mode 100644 index 0000000000000..55f8c05a43a8d --- /dev/null +++ b/apps/site/tests/e2e/carbon-ads.spec.ts @@ -0,0 +1,101 @@ +import { test, expect, type Page } from '@playwright/test'; + +const zones = { top: 'CWBI6KJY', sidebar: 'CWBI6KJU' }; + +const createAd = (id: string) => ({ + company: `Company ${id}`, + description: `Description ${id}`, + callToAction: 'Learn more', + image: 'https://cdn4.buysellads.net/test.png', + largeImage: 'https://cdn4.buysellads.net/test.png', + backgroundColor: '#ffffff', + textColor: '#000000', + ctaBackgroundColor: '#000000', + ctaTextColor: '#ffffff', + statlink: `https://srv.buysellads.com/ads/click/x/${id}`, + statview: `https://srv.buysellads.com/ads/view/x/${id}`, + ad_via_link: 'https://www.carbonads.net/', +}); + +/** + * Serves fixtures instead of contacting Carbon Ads, and records every ad request + */ +const mockAds = async (page: Page) => { + const requests: Array = []; + + await page.route('https://srv.carbonads.net/**', route => { + const zone = new URL(route.request().url()).pathname; + requests.push(zone); + + return route.fulfill({ + json: { ads: [createAd(String(requests.length))] }, + headers: { 'access-control-allow-origin': '*' }, + }); + }); + + await page.route('https://srv.buysellads.com/**', route => + route.fulfill({ body: '' }) + ); + + await page.route('https://cdn4.buysellads.net/**', route => + route.fulfill({ body: '' }) + ); + + return requests; +}; + +const adLinks = (page: Page) => + page.locator('a[href^="https://srv.buysellads.com/ads/click/"]'); + +test.describe('Carbon Ads', () => { + test.use({ viewport: { width: 1440, height: 900 } }); + + test('should never show or request ads on the homepage', async ({ page }) => { + const requests = await mockAds(page); + + await page.goto('/en'); + await page.waitForLoadState('networkidle'); + + expect(requests).toEqual([]); + await expect(adLinks(page)).toHaveCount(0); + }); + + test('should request each format once per page view', async ({ page }) => { + const requests = await mockAds(page); + + await page.goto('/en/about'); + await expect(adLinks(page)).toHaveCount(2); + + expect(requests.sort()).toEqual([ + `/ads/${zones.sidebar}.json`, + `/ads/${zones.top}.json`, + ]); + + // Client-side navigation starts a new page view (Top Bar only) + await page.locator('nav a[href="/en/blog"]').first().click(); + await page.waitForURL('**/en/blog'); + await expect(adLinks(page)).toHaveCount(1); + await expect(page.getByAltText('Company 3')).toBeVisible(); + + expect(requests).toHaveLength(3); + + // Navigating to the homepage removes the ads, without new requests + await page.locator('nav a[href="/en"]').first().click(); + await page.waitForURL(/\/en$/); + await page.waitForLoadState('networkidle'); + + await expect(adLinks(page)).toHaveCount(0); + expect(requests).toHaveLength(3); + }); + + test('should not request the Sidebar on narrow screens', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + + const requests = await mockAds(page); + + await page.goto('/en/about'); + await expect(adLinks(page)).toHaveCount(1); + + expect(requests).toEqual([`/ads/${zones.top}.json`]); + }); +}); diff --git a/docs/carbon-ads.md b/docs/carbon-ads.md index 6c6bdf09d2d33..277a2bf6fbf3a 100644 --- a/docs/carbon-ads.md +++ b/docs/carbon-ads.md @@ -55,4 +55,4 @@ There is no Content Security Policy on the Website at the moment. If one is adde ## Testing -There are no unit tests for the integration yet. When testing manually, avoid clicking real ads repeatedly; prefer intercepting `https://srv.carbonads.net/ads/*.json` with fixtures (e.g. in Playwright or the browser's developer tools). +End-to-end tests live in `apps/site/tests/e2e/carbon-ads.spec.ts`, and impression accounting is unit tested in `apps/site/hooks/__tests__/useCarbonAd.test.jsx`. When testing manually, avoid clicking real ads repeatedly; prefer intercepting `https://srv.carbonads.net/ads/*.json` with fixtures (e.g. in Playwright or the browser's developer tools).