How to test cookie consent banner
Load the page in a clean profile, read the cookie jar with CDP Network.getAllCookies, click Accept all, read it again. Repeat in a second clean profile with Reject all, then diff the two jars name by name. On the fixture below, five of six cookies were identical after both clicks.
Why check this
Run this on any release that touches the banner vendor, the tag manager, or the map from a consent category to the scripts it gates. It belongs in regression, because the banner keeps rendering and the button keeps responding long after the branch behind it stopped working.
The failure it catches is the commonest one in this area. A visitor clicks Reject all, the banner hides, the page looks settled, and the analytics cookies are written anyway because only the banner's own state cookie is wired to the decision. No screenshot shows it. No console message reports it. The only evidence is two cookie jars read after two different clicks and compared name by name.
Reading the jar through the DevTools Protocol rather than through document.cookie matters here, because a server-set HttpOnly tracking cookie is invisible to script. A test built on document.cookie reports a shorter list than the browser is holding and can pass while the defect is present.
Prerequisites
- Node 22 and the Chrome already installed. Nothing is downloaded except the driver.
npm i puppeteer-corein the directory that holds the scripts. The jar is read with Network.getAllCookies, which returns every cookie in the profile, including the ones script cannot see.- A fresh profile for each run.
mkdtempgives one, and the script deletes it afterwards. A jar carried over from an earlier run makes the two results agree for the wrong reason. - The fixture below, saved as
consent-fixture.mjsand started before the runs. Every figure on this page comes from it, so nothing here touches a host this project does not own. - The numbers are one capture, on one machine, with Chrome 152 on Windows, on 2026-09-12.
// consent-fixture.mjs node consent-fixture.mjs -> http://localhost:9601/
import { createServer } from 'node:http';
const YEAR = 'Max-Age=31536000; Path=/';
const records = new Map(); // sid -> consent record
const html = `<!doctype html><meta charset="utf-8"><title>Fixture shop</title>
<h1>Fixture shop</h1><p id="state">consent: not recorded</p>
<div id="banner"><p>We use cookies for analytics and marketing.</p>
<button id="accept">Accept all</button> <button id="reject">Reject all</button></div>
<button id="withdraw">Withdraw consent</button>
<script>
const send = (d) => fetch('/consent', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ decision: d }) }).then((r) => r.json()).then((j) => {
state.textContent = 'consent: ' + j.decision;
banner.hidden = j.decision !== 'withdraw';
localStorage.setItem('fx_consent', JSON.stringify({ decision: j.decision, at: j.at }));
if (j.decision === 'accept') localStorage.setItem('fx_analytics_queue', '["pageview:/"]');
sessionStorage.setItem('fx_banner_seen', '1');
});
accept.onclick = () => send('accept');
reject.onclick = () => send('reject');
withdraw.onclick = () => send('withdraw');
</script>`;
createServer((req, res) => {
const sid = /fx_sid=([^;]+)/.exec(req.headers.cookie || '')?.[1] || 'sid-7f3a';
if (req.url === '/echo') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ cookieHeader: req.headers.cookie || null }));
}
if (req.url === '/consent/record') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(records.get(sid) || null, null, 2));
}
if (req.url === '/consent' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
return req.on('end', () => {
const decision = JSON.parse(body).decision;
const at = new Date().toISOString();
const prev = records.get(sid);
records.set(sid, decision === 'withdraw'
? { ...prev, decision: 'withdraw', withdrawnAt: at }
: { id: 'cr-' + Math.random().toString(36).slice(2, 10), subject: sid, decision, at,
categories: { necessary: true, analytics: decision === 'accept', marketing: decision === 'accept' },
policyVersion: '2026-03-01', bannerVersion: '1.4.0', scope: 'http://localhost:9601',
method: 'banner-button' });
// The defect under test: both decisions set the same analytics cookies.
const jar = decision === 'withdraw' ? [] : [`fx_ga=GA1.2.884.1789; ${YEAR}`, `fx_fbp=fb.1.771; ${YEAR}`,
`fx_uid=u-91af; HttpOnly; ${YEAR}`];
res.writeHead(200, { 'content-type': 'application/json',
'set-cookie': [...jar, `fx_consent=${decision}; ${YEAR}`] });
res.end(JSON.stringify({ decision, at }));
});
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
'set-cookie': [`fx_sid=${sid}; HttpOnly; Path=/`, `fx_seen=1; ${YEAR}`] });
res.end(html + (req.url.includes('tp=1') ? '<img src="http://127.0.0.1:9602/px.gif" alt="">' : ''));
}).listen(9601, () => console.log('fixture on http://localhost:9601/'));
// banner-run.mjs node banner-run.mjs accept|reject
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const decision = process.argv[2];
const profile = mkdtempSync(join(tmpdir(), 'consent-')); // a clean jar every run
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile });
try {
const page = (await browser.pages())[0];
const cdp = await page.createCDPSession();
await cdp.send('Network.enable');
const jar = async () => (await cdp.send('Network.getAllCookies')).cookies
.filter((c) => c.domain === 'localhost').map((c) => `${c.name}=${c.value}`).sort();
await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
console.log('before any click :', (await jar()).join(' '));
console.log('banner visible :', await page.$eval('#banner', (e) => !e.hidden));
await page.click('#' + decision);
await page.waitForFunction("state.textContent !== 'consent: not recorded'");
const after = await jar();
console.log(`after "${decision}" :`, after.join(' '));
console.log('banner visible :', await page.$eval('#banner', (e) => !e.hidden));
writeFileSync(`jar-${decision}.json`, JSON.stringify(after, null, 1));
} finally {
await browser.close();
rmSync(profile, { recursive: true, force: true });
}
// jar-diff.mjs node jar-diff.mjs
import { readFileSync } from 'node:fs';
const pairs = (f) => new Map(readFileSync(f, 'utf8').match(/"([^"]+)"/g)
.map((s) => s.slice(1, -1).split(/=(.*)/)).map(([n, v]) => [n, v]));
const a = pairs('jar-accept.json');
const r = pairs('jar-reject.json');
for (const name of new Set([...a.keys(), ...r.keys()]).values()) {
const av = a.get(name), rv = r.get(name);
console.log(`${name.padEnd(11)} accept=${String(av).padEnd(16)} reject=${String(rv).padEnd(16)} ${av === rv ? 'SAME' : 'differs'}`);
}
Steps
- Step 1.
Start the fixture and leave it running.
node consent-fixture.mjsfixture on http://localhost:9601/Port 9601 is the target for every step below.
- Step 2.
Run the accept path in a clean profile.
node banner-run.mjs acceptbefore any click : fx_seen=1 fx_sid=sid-7f3a banner visible : true after "accept" : fx_consent=accept fx_fbp=fb.1.771 fx_ga=GA1.2.884.1789 fx_seen=1 fx_sid=sid-7f3a fx_uid=u-91af banner visible : falseTwo cookies existed before the click and six after it.
fx_uidisHttpOnly, so it is in this list only because the jar was read over CDP. - Step 3.
Run the reject path in a second clean profile.
node banner-run.mjs rejectbefore any click : fx_seen=1 fx_sid=sid-7f3a banner visible : true after "reject" : fx_consent=reject fx_fbp=fb.1.771 fx_ga=GA1.2.884.1789 fx_seen=1 fx_sid=sid-7f3a fx_uid=u-91af banner visible : falseThe banner hid itself and the state cookie says
reject. Read the rest of the line before calling the test passed. - Step 4.
Compare the two jars name by name.
node jar-diff.mjsfx_consent accept=accept reject=reject differs fx_fbp accept=fb.1.771 reject=fb.1.771 SAME fx_ga accept=GA1.2.884.1789 reject=GA1.2.884.1789 SAME fx_seen accept=1 reject=1 SAME fx_sid accept=sid-7f3a reject=sid-7f3a SAME fx_uid accept=u-91af reject=u-91af SAMEOne row differs, and it is the banner's own state cookie. The analytics pair and the server-set identifier are byte for byte the same after a rejection as after an acceptance. That is the defect, and it is the whole finding.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Only the state cookie differs | The decision reaches the banner and nothing else | Raise it as a functional defect, not a wording one. The consent value is stored and ignored. |
| The reject jar holds fewer names | The gate works for the cookies that are missing | Name the remaining ones and check each against the category map. |
| A cookie appears before any click | Something writes before a decision exists | Go to How to check if cookies are set without consent. |
| The jar is longer than document.cookie | Server-set HttpOnly cookies are present | Keep reading the jar over CDP. A script-based test under-reports here. |
| Both jars are identical, state cookie included | The click never reached the server | Confirm the POST in the network log before blaming the cookie logic. |
Common mistakes
What to check next
- How to check cookies on a website: the full inventory behind the two lines this page diffed, with domain, path and lifetime.
- How to check if cookies are set without consent: the earlier failure, where something writes before any button is pressed.
- How to check a consent record: whether the decision was stored in a form that can be produced later.
- How to revoke cookie consent: the same comparison after the reader changes their mind.
- How to check if tracking scripts load before consent: the requests behind the cookies, which fire even when no cookie is written.
FAQ
How do I check if cookie consent is working?
Compare cookie jars, not screens. Read the jar in a clean profile after Accept all and after Reject all, then diff them. If the only difference is the banner's own state cookie, the decision is stored and not applied.
Why are cookies still tracking after reject all?
Usually one branch. The consent callback writes the state cookie and hides the banner on both paths, while the code that sets the analytics cookies runs unconditionally. The fixture on this page reproduces that shape in nine lines.
Can I test a cookie banner without a browser?
Not fully. Reading Set-Cookie with curl shows what arrives on the first response, but the banner decision travels through a click and a script. The click needs a real browser, and the jar read needs the DevTools Protocol.
Does the test need a new profile every time?
Yes. A cookie from a previous run persists in the profile and lands in the next jar, which makes the accept and reject results converge for a reason that has nothing to do with the banner.
What about cookies a script sets, rather than the server?
The jar holds both. Network.getAllCookies returns the profile's cookie store, so a cookie written by document.cookie in page script and a cookie from a Set-Cookie header appear in the same list.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny