mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-20 16:54:17 +00:00
refactor(privacy-banner): render as a persistent gritter, not custom DOM
Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.
The two dismissal modes survive intact:
- dismissible: gritter closes on (X); before_close persists a flag
in localStorage so the notice is suppressed on subsequent loads.
- sticky: closes for the current session only; never persists; the
next pad load shows it again.
learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).
Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8891d105ce
commit
906e145aaa
4 changed files with 213 additions and 176 deletions
|
|
@ -22,8 +22,6 @@ const storageKey = (url: string): string => {
|
||||||
const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
|
const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
|
||||||
const safeUrl = (href: string | null | undefined): string | null => {
|
const safeUrl = (href: string | null | undefined): string | null => {
|
||||||
if (typeof href !== 'string' || href === '') return null;
|
if (typeof href !== 'string' || href === '') return null;
|
||||||
// Reject protocol-relative and scheme-less values that the browser might
|
|
||||||
// resolve to something unexpected. Require an explicit scheme.
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(href, location.href);
|
parsed = new URL(href, location.href);
|
||||||
|
|
@ -34,10 +32,32 @@ const safeUrl = (href: string | null | undefined): string | null => {
|
||||||
return parsed.href;
|
return parsed.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a jQuery DOM fragment for the gritter `text` parameter. Each line of
|
||||||
|
// the body becomes its own <p> (mirrors what the original config supports), and
|
||||||
|
// an optional "Learn more" anchor is appended only after the URL has passed
|
||||||
|
// through safeUrl().
|
||||||
|
const buildBody = (config: BannerConfig): JQuery => {
|
||||||
|
const $ = (window as any).$;
|
||||||
|
const wrap = $('<div>');
|
||||||
|
for (const line of (config.body || '').split(/\r?\n/)) {
|
||||||
|
wrap.append($('<p>').text(line));
|
||||||
|
}
|
||||||
|
const safeHref = safeUrl(config.learnMoreUrl);
|
||||||
|
if (safeHref != null) {
|
||||||
|
wrap.append($('<p>').append(
|
||||||
|
$('<a>')
|
||||||
|
.attr('href', safeHref)
|
||||||
|
.attr('target', '_blank')
|
||||||
|
.attr('rel', 'noopener')
|
||||||
|
.text('Learn more')));
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
|
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
|
||||||
if (!config || !config.enabled) return;
|
if (!config || !config.enabled) return;
|
||||||
const banner = document.getElementById('privacy-banner');
|
const $ = (window as any).$;
|
||||||
if (banner == null) return;
|
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
|
||||||
|
|
||||||
if (config.dismissal === 'dismissible') {
|
if (config.dismissal === 'dismissible') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -45,47 +65,28 @@ export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) =>
|
||||||
} catch (_e) { /* proceed without persistence */ }
|
} catch (_e) { /* proceed without persistence */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null;
|
// Reused class lets the Playwright spec target this specific gritter without
|
||||||
if (titleEl) titleEl.textContent = config.title || '';
|
// affecting its appearance — the gritter looks like every other gritter on
|
||||||
|
// the page.
|
||||||
const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null;
|
$.gritter.add({
|
||||||
if (bodyEl) {
|
title: config.title || '',
|
||||||
bodyEl.textContent = '';
|
text: buildBody(config),
|
||||||
for (const line of (config.body || '').split(/\r?\n/)) {
|
sticky: true,
|
||||||
const p = document.createElement('p');
|
position: 'bottom',
|
||||||
p.textContent = line;
|
class_name: 'privacy-notice',
|
||||||
bodyEl.appendChild(p);
|
before_close: () => {
|
||||||
}
|
if (config.dismissal !== 'dismissible') return;
|
||||||
}
|
try {
|
||||||
|
localStorage.setItem(storageKey(location.href), '1');
|
||||||
const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null;
|
} catch (_e) { /* best-effort */ }
|
||||||
if (linkEl) {
|
},
|
||||||
linkEl.replaceChildren();
|
});
|
||||||
const safeHref = safeUrl(config.learnMoreUrl);
|
|
||||||
if (safeHref != null) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = safeHref;
|
|
||||||
a.target = '_blank';
|
|
||||||
a.rel = 'noopener';
|
|
||||||
a.textContent = 'Learn more';
|
|
||||||
linkEl.appendChild(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null;
|
|
||||||
if (closeBtn) {
|
|
||||||
if (config.dismissal === 'dismissible') {
|
|
||||||
closeBtn.hidden = false;
|
|
||||||
closeBtn.onclick = () => {
|
|
||||||
banner.hidden = true;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(storageKey(location.href), '1');
|
|
||||||
} catch (_e) { /* best-effort */ }
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
closeBtn.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
banner.hidden = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// End-to-end test hook. The privacy_banner module is bundled into pad.js so
|
||||||
|
// the Playwright spec at src/tests/frontend-new/specs/privacy_banner.spec.ts
|
||||||
|
// has no other way to reach into the real showPrivacyBannerIfEnabled — without
|
||||||
|
// this it can only toy with the DOM and never proves the config-to-DOM wiring.
|
||||||
|
// Namespaced under __etherpad_privacyBanner__ so it can't collide with site
|
||||||
|
// code.
|
||||||
|
(globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled};
|
||||||
|
|
|
||||||
|
|
@ -175,50 +175,3 @@
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GDPR privacy banner (PR4) */
|
|
||||||
/* `.privacy-banner[hidden]` (class+attr, specificity 0,2,0) outranks the
|
|
||||||
* `.privacy-banner { display: flex }` rule below (0,1,0), so the UA `[hidden]`
|
|
||||||
* default isn't clobbered and we don't need `!important`. */
|
|
||||||
.privacy-banner[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0.5rem 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: #fff7d6;
|
|
||||||
border: 1px solid #e0c97a;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner .privacy-banner-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner .privacy-banner-title {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner .privacy-banner-body p {
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner .privacy-banner-link a {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.privacy-banner .privacy-banner-close {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,6 @@
|
||||||
|
|
||||||
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
|
<% e.begin_block("afterEditbar"); %><% e.end_block(); %>
|
||||||
|
|
||||||
<div id="privacy-banner" class="privacy-banner" hidden>
|
|
||||||
<div class="privacy-banner-content">
|
|
||||||
<strong class="privacy-banner-title"></strong>
|
|
||||||
<div class="privacy-banner-body"></div>
|
|
||||||
<div class="privacy-banner-link"></div>
|
|
||||||
</div>
|
|
||||||
<button id="privacy-banner-close" type="button"
|
|
||||||
class="privacy-banner-close" aria-label="Dismiss" hidden>×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="editorcontainerbox" class="flex-layout">
|
<div id="editorcontainerbox" class="flex-layout">
|
||||||
|
|
||||||
<% e.begin_block("editorContainerBox"); %>
|
<% e.begin_block("editorContainerBox"); %>
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,199 @@
|
||||||
import {expect, test, Page} from '@playwright/test';
|
import {expect, test, Page} from '@playwright/test';
|
||||||
import {randomUUID} from 'node:crypto';
|
import {randomUUID} from 'node:crypto';
|
||||||
|
|
||||||
|
type BannerConfig = {
|
||||||
|
enabled: boolean,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
learnMoreUrl: string | null,
|
||||||
|
dismissal: 'dismissible' | 'sticky',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'etherpad.privacyBanner.dismissed:';
|
||||||
|
// All gritters render into #gritter-container.bottom for this feature; we tag
|
||||||
|
// our gritter with `class_name: 'privacy-notice'` so tests can target it
|
||||||
|
// regardless of whatever else the pad may surface.
|
||||||
|
const NOTICE = '#gritter-container.bottom .gritter-item.privacy-notice';
|
||||||
|
|
||||||
const freshPad = async (page: Page) => {
|
const freshPad = async (page: Page) => {
|
||||||
const padId = `FRONTEND_TESTS${randomUUID()}`;
|
const padId = `FRONTEND_TESTS${randomUUID()}`;
|
||||||
await page.goto(`http://localhost:9001/p/${padId}`);
|
await page.goto(`http://localhost:9001/p/${padId}`);
|
||||||
await page.waitForSelector('iframe[name="ace_outer"]');
|
await page.waitForSelector('iframe[name="ace_outer"]');
|
||||||
await page.waitForSelector('#editorcontainer.initialized');
|
await page.waitForSelector('#editorcontainer.initialized');
|
||||||
|
// Drop any persisted dismissal flag from a previous test run on this origin
|
||||||
|
// so dismissible scenarios start from a clean state regardless of order.
|
||||||
|
await page.evaluate((prefix) => {
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k && k.startsWith(prefix)) localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
}, STORAGE_PREFIX);
|
||||||
return padId;
|
return padId;
|
||||||
};
|
};
|
||||||
|
|
||||||
test.describe('privacy banner', () => {
|
const showBanner = (page: Page, config: BannerConfig) =>
|
||||||
|
page.evaluate((cfg) => {
|
||||||
|
(window as any).__etherpad_privacyBanner__.show(cfg);
|
||||||
|
}, config);
|
||||||
|
|
||||||
|
test.describe('privacy banner (gritter-based)', () => {
|
||||||
test.beforeEach(async ({context}) => {
|
test.beforeEach(async ({context}) => {
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disabled by default — banner stays hidden', async ({page}) => {
|
test('disabled by default — no privacy gritter is shown', async ({page}) => {
|
||||||
await freshPad(page);
|
await freshPad(page);
|
||||||
await expect(page.locator('#privacy-banner')).toBeHidden();
|
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sticky banner is visible and has no close button', async ({page}) => {
|
test('enabled=false leaves the page free of a privacy gritter', async ({page}) => {
|
||||||
await freshPad(page);
|
await freshPad(page);
|
||||||
await page.evaluate(() => {
|
await showBanner(page, {
|
||||||
const banner = document.getElementById('privacy-banner')!;
|
enabled: false,
|
||||||
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
|
title: 'Should not render',
|
||||||
const body = banner.querySelector('.privacy-banner-body')!;
|
body: 'Should not render',
|
||||||
body.textContent = '';
|
learnMoreUrl: null,
|
||||||
const p = document.createElement('p');
|
dismissal: 'sticky',
|
||||||
p.textContent = 'Body text';
|
|
||||||
body.appendChild(p);
|
|
||||||
(banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true;
|
|
||||||
banner.hidden = false;
|
|
||||||
});
|
});
|
||||||
await expect(page.locator('#privacy-banner')).toBeVisible();
|
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||||
await expect(page.locator('#privacy-banner-close')).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dismissible — close button hides and persists in localStorage',
|
test('renders title, body paragraphs, and link as a sticky bottom gritter',
|
||||||
async ({page}) => {
|
async ({page}) => {
|
||||||
await freshPad(page);
|
await freshPad(page);
|
||||||
await page.evaluate(() => {
|
await showBanner(page, {
|
||||||
const banner = document.getElementById('privacy-banner')!;
|
enabled: true,
|
||||||
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
|
title: 'Privacy notice',
|
||||||
const body = banner.querySelector('.privacy-banner-body')!;
|
body: 'First paragraph.\nSecond paragraph.',
|
||||||
body.textContent = '';
|
learnMoreUrl: 'https://example.com/privacy',
|
||||||
const p = document.createElement('p');
|
dismissal: 'sticky',
|
||||||
p.textContent = 'Body text';
|
|
||||||
body.appendChild(p);
|
|
||||||
const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement;
|
|
||||||
close.hidden = false;
|
|
||||||
close.onclick = () => {
|
|
||||||
banner.hidden = true;
|
|
||||||
localStorage.setItem(
|
|
||||||
`etherpad.privacyBanner.dismissed:${location.origin}`, '1');
|
|
||||||
};
|
|
||||||
banner.hidden = false;
|
|
||||||
});
|
});
|
||||||
await page.locator('#privacy-banner-close').click();
|
const item = page.locator(NOTICE);
|
||||||
await expect(page.locator('#privacy-banner')).toBeHidden();
|
await expect(item).toBeVisible();
|
||||||
|
await expect(item).toHaveClass(/sticky/);
|
||||||
|
await expect(item.locator('.gritter-title')).toHaveText('Privacy notice');
|
||||||
|
// The body lines become two <p>s; the optional link adds a third.
|
||||||
|
const paragraphs = item.locator('.gritter-content > p, .gritter-content div p');
|
||||||
|
await expect(paragraphs).toHaveCount(3);
|
||||||
|
await expect(paragraphs.nth(0)).toHaveText('First paragraph.');
|
||||||
|
await expect(paragraphs.nth(1)).toHaveText('Second paragraph.');
|
||||||
|
const link = item.locator('a');
|
||||||
|
await expect(link).toHaveAttribute('href', 'https://example.com/privacy');
|
||||||
|
await expect(link).toHaveAttribute('rel', 'noopener');
|
||||||
|
await expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dismissible — clicking gritter close persists flag in localStorage',
|
||||||
|
async ({page}) => {
|
||||||
|
await freshPad(page);
|
||||||
|
await showBanner(page, {
|
||||||
|
enabled: true,
|
||||||
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: null,
|
||||||
|
dismissal: 'dismissible',
|
||||||
|
});
|
||||||
|
const item = page.locator(NOTICE);
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
await item.locator('.gritter-close').click();
|
||||||
|
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||||
|
|
||||||
const flag = await page.evaluate(
|
const flag = await page.evaluate(
|
||||||
() => localStorage.getItem(
|
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
|
||||||
`etherpad.privacyBanner.dismissed:${location.origin}`));
|
STORAGE_PREFIX);
|
||||||
expect(flag).toBe('1');
|
expect(flag).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('javascript: learnMoreUrl is rejected; https is allowed', async ({page}) => {
|
test('dismissible — pre-existing localStorage flag suppresses the gritter',
|
||||||
await freshPad(page);
|
async ({page}) => {
|
||||||
const results = await page.evaluate(async () => {
|
await freshPad(page);
|
||||||
// Load the compiled privacy_banner module and call
|
await page.evaluate(
|
||||||
// showPrivacyBannerIfEnabled with a javascript:/https: URL each time;
|
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
|
||||||
// assert that the resulting <a href="…"> is either missing (blocked)
|
STORAGE_PREFIX);
|
||||||
// or points at the safe URL.
|
await showBanner(page, {
|
||||||
const bannerEl = document.getElementById('privacy-banner')!;
|
enabled: true,
|
||||||
const linkEl = bannerEl.querySelector('.privacy-banner-link') as HTMLElement;
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: null,
|
||||||
|
dismissal: 'dismissible',
|
||||||
|
});
|
||||||
|
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
const run = (url: string) => {
|
test('sticky — closing the gritter does NOT persist a dismissal flag',
|
||||||
linkEl.replaceChildren();
|
async ({page}) => {
|
||||||
const SAFE = new Set(['http:', 'https:', 'mailto:']);
|
// sticky mode means "show on every load"; the close button still
|
||||||
let safe: string | null = null;
|
// works for the current session but must not store a flag.
|
||||||
try {
|
await freshPad(page);
|
||||||
const parsed = new URL(url, location.href);
|
await showBanner(page, {
|
||||||
if (SAFE.has(parsed.protocol)) safe = parsed.href;
|
enabled: true,
|
||||||
} catch (_e) { /* not a URL — leave safe=null */ }
|
title: 'Privacy notice',
|
||||||
if (safe != null) {
|
body: 'Body.',
|
||||||
const a = document.createElement('a');
|
learnMoreUrl: null,
|
||||||
a.href = safe;
|
dismissal: 'sticky',
|
||||||
linkEl.appendChild(a);
|
});
|
||||||
}
|
const item = page.locator(NOTICE);
|
||||||
const a = linkEl.querySelector('a');
|
await expect(item).toBeVisible();
|
||||||
return a ? a.getAttribute('href') : null;
|
await item.locator('.gritter-close').click();
|
||||||
};
|
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||||
return {
|
|
||||||
javascript: run('javascript:alert(1)'),
|
const flag = await page.evaluate(
|
||||||
dataUrl: run('data:text/html,<script>alert(1)</script>'),
|
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
|
||||||
https: run('https://example.com/privacy'),
|
STORAGE_PREFIX);
|
||||||
mailto: run('mailto:privacy@example.com'),
|
expect(flag).toBeNull();
|
||||||
};
|
});
|
||||||
|
|
||||||
|
test('sticky — pre-existing localStorage flag is ignored',
|
||||||
|
async ({page}) => {
|
||||||
|
await freshPad(page);
|
||||||
|
await page.evaluate(
|
||||||
|
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
|
||||||
|
STORAGE_PREFIX);
|
||||||
|
await showBanner(page, {
|
||||||
|
enabled: true,
|
||||||
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: null,
|
||||||
|
dismissal: 'sticky',
|
||||||
|
});
|
||||||
|
await expect(page.locator(NOTICE)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: learnMoreUrl is rejected — no anchor rendered',
|
||||||
|
async ({page}) => {
|
||||||
|
await freshPad(page);
|
||||||
|
await showBanner(page, {
|
||||||
|
enabled: true,
|
||||||
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: 'javascript:alert(1)',
|
||||||
|
dismissal: 'sticky',
|
||||||
|
});
|
||||||
|
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data: learnMoreUrl is rejected — no anchor rendered', async ({page}) => {
|
||||||
|
await freshPad(page);
|
||||||
|
await showBanner(page, {
|
||||||
|
enabled: true,
|
||||||
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: 'data:text/html,<script>alert(1)</script>',
|
||||||
|
dismissal: 'sticky',
|
||||||
});
|
});
|
||||||
expect(results.javascript).toBeNull();
|
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
|
||||||
expect(results.dataUrl).toBeNull();
|
});
|
||||||
expect(results.https).toBe('https://example.com/privacy');
|
|
||||||
expect(results.mailto).toBe('mailto:privacy@example.com');
|
test('mailto: learnMoreUrl is allowed', async ({page}) => {
|
||||||
|
await freshPad(page);
|
||||||
|
await showBanner(page, {
|
||||||
|
enabled: true,
|
||||||
|
title: 'Privacy notice',
|
||||||
|
body: 'Body.',
|
||||||
|
learnMoreUrl: 'mailto:privacy@example.com',
|
||||||
|
dismissal: 'sticky',
|
||||||
|
});
|
||||||
|
await expect(page.locator(`${NOTICE} a`))
|
||||||
|
.toHaveAttribute('href', 'mailto:privacy@example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue