From 61c0aff7ee83643cf0b8a7ae0f4e55a883d51a56 Mon Sep 17 00:00:00 2001 From: Mick Date: Wed, 26 Aug 2026 16:05:24 +0100 Subject: [PATCH 01/18] Remove boilerplate package --- README.md | 1 - lerna.json | 2 +- packages/boilerplate/.npmignore | 7 -- packages/boilerplate/README.md | 81 --------------- .../__tests__/playwright/playwright.spec.js | 35 ------- .../boilerplate/__tests__/unit/index.test.js | 97 ------------------ packages/boilerplate/example/src/favicon.ico | Bin 1150 -> 0 bytes packages/boilerplate/example/src/index.html | 85 --------------- packages/boilerplate/example/src/js/index.js | 5 - packages/boilerplate/package.json | 31 ------ packages/boilerplate/playwright.config.js | 16 --- packages/boilerplate/src/index.js | 22 ---- packages/boilerplate/src/lib/defaults.js | 4 - packages/boilerplate/src/lib/factory.js | 23 ----- packages/boilerplate/src/lib/utils.js | 13 --- .../tools/playwright.rspack.config.js | 8 -- packages/boilerplate/tools/rspack.config.js | 42 -------- 17 files changed, 1 insertion(+), 471 deletions(-) delete mode 100644 packages/boilerplate/.npmignore delete mode 100644 packages/boilerplate/README.md delete mode 100644 packages/boilerplate/__tests__/playwright/playwright.spec.js delete mode 100644 packages/boilerplate/__tests__/unit/index.test.js delete mode 100644 packages/boilerplate/example/src/favicon.ico delete mode 100644 packages/boilerplate/example/src/index.html delete mode 100644 packages/boilerplate/example/src/js/index.js delete mode 100644 packages/boilerplate/package.json delete mode 100644 packages/boilerplate/playwright.config.js delete mode 100644 packages/boilerplate/src/index.js delete mode 100644 packages/boilerplate/src/lib/defaults.js delete mode 100644 packages/boilerplate/src/lib/factory.js delete mode 100644 packages/boilerplate/src/lib/utils.js delete mode 100644 packages/boilerplate/tools/playwright.rspack.config.js delete mode 100644 packages/boilerplate/tools/rspack.config.js diff --git a/README.md b/README.md index 1c689ab5..5f95a835 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ Each package is available for use on npm. See [the docs](https://github.com/stor | Package | Description | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | [Autocomplete](./packages/autocomplete) | Accessible autocomplete / combobox (single, multiple, async and ` I am OK with this @@ -111,7 +111,7 @@ const cookieBanner = banner({ No thank you @@ -139,15 +139,20 @@ euConsentTypes: { } ``` +## Clearing cookies +The banner has no map of which cookies belong to which consent category, so it cannot selectively remove cookies for a single withdrawn category. Cookie clearing therefore happens only on **Reject all**, where withdrawing every category makes a full wipe correct. + +Setting an individual category to "No" via the preferences form records that preference (and stops that category's functions running on the next page load), but does **not** delete cookies that category may already have set in the current session. If you need those removed immediately, reload the page after saving, or listen for the `banner.consent` event and clear the specific cookies yourself. + ## Options Full options that can be passed during initialisation: ``` { name: '.CookiePreferences', //name of the cookie set to record user consent path: '/', //path of the preferences cookie - domain: window.location.hostname === 'localhost' ? '' : `.${removeSubdomain(window.location.hostname)}`, //domain of the preferences cookie, defaults to . + domain: '.', //auto-derived so consent is shared across subdomains ('' host-only on localhost/IP); override to scope it yourself secure: true, //preferences cookie secure - samesite: 'lax', //preferences cookie samesite + samesite: 'strict', //preferences cookie samesite expiry: 365, //preferences cookie expiry in days types: {}, //types of cookie-dependent functionality euConsentTypes: {}, //map Google EU consent categories to types of cookie defined in 'types' @@ -184,6 +189,8 @@ When using the cookie banner, you **must** provide suitable HTML templates for b The templates should take the form of a function which returns a valid HTML string. The functions will be passed a 'model' which will expose the state of the cookie banner to help with rendering. See the example config above as a startring point. +All three templates (`bannerTemplate`, `formTemplate`, `messageTemplate`) receive the same model shape: the settings spread at the top level plus `settings` and `consent`. So both `model.classNames.banner` and `model.settings.classNames.banner` resolve, and `model.consent` is available in every template. + If these functions are not provided, the cookie banner will fail to initialise. ## Utility functions diff --git a/packages/cookie-banner/__tests__/unit/clear-cookies.test.js b/packages/cookie-banner/__tests__/unit/clear-cookies.test.js new file mode 100644 index 00000000..b87ff73c --- /dev/null +++ b/packages/cookie-banner/__tests__/unit/clear-cookies.test.js @@ -0,0 +1,66 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import cookieBanner from '../../src/index.js'; +import defaults from '../../src/lib/defaults.js'; +import sampleTemplates from '../../example/src/js/sample-templates.js'; + +const types = { + performance: { + title: 'Performance preferences', + description: 'Performance description', + labels: { yes: 'yes', no: 'no' }, + fns: [() => { }] + }, + ads: { + title: 'Ads preferences', + description: 'Ads description', + labels: { yes: 'yes', no: 'no' }, + fns: [() => { }] + } +}; + +const clearAllCookies = () => document.cookie.split('; ').forEach(cookie => { + const name = cookie.split('=')[0]; + if (name) document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; +}); + +const readCookieValue = name => { + const match = document.cookie.split('; ').find(cookie => cookie.split('=')[0] === name); + return match ? match.slice(match.indexOf('=') + 1) : undefined; +}; + +describe('Cookie banner > cookie clearing', () => { + beforeEach(() => { + clearAllCookies(); + document.body.innerHTML = `
`; + }); + + it('reject-all clears other cookies (deleteCookies runs on reject)', async () => { + cookieBanner({ ...sampleTemplates, secure: false, types }); + document.cookie = 'unrelated=keep; path=/'; + assert.equal(readCookieValue('unrelated'), 'keep'); + + document.querySelector(`.${defaults.classNames.rejectBtn}`).click(); + + assert.equal(readCookieValue('unrelated'), undefined, 'reject should have wiped the unrelated cookie'); + // the consent cookie is re-written after the wipe + assert.equal(readCookieValue(defaults.name), btoa(JSON.stringify({ consent: { performance: 0, ads: 0 } }))); + }); + + it('accept-all leaves other cookies in place (deleteCookies does not run on accept)', async () => { + cookieBanner({ ...sampleTemplates, secure: false, types }); + document.cookie = 'unrelated=keep; path=/'; + assert.equal(readCookieValue('unrelated'), 'keep'); + + document.querySelector(`.${defaults.classNames.acceptBtn}`).click(); + + assert.equal(readCookieValue('unrelated'), 'keep', 'accept should not wipe the unrelated cookie'); + assert.equal(readCookieValue(defaults.name), btoa(JSON.stringify({ consent: { performance: 1, ads: 1 } }))); + }); + + it('renders the banner without throwing when the body has no element children', async () => { + document.body.innerHTML = ''; + assert.doesNotThrow(() => cookieBanner({ ...sampleTemplates, secure: false, types })); + assert.notStrictEqual(document.querySelector(`.${defaults.classNames.banner}`), null); + }); +}); diff --git a/packages/cookie-banner/__tests__/unit/state.test.js b/packages/cookie-banner/__tests__/unit/state.test.js index 9536506b..025d09c2 100644 --- a/packages/cookie-banner/__tests__/unit/state.test.js +++ b/packages/cookie-banner/__tests__/unit/state.test.js @@ -1,9 +1,11 @@ -import { describe, it, before } from 'node:test'; +import { describe, it, before, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import cookieBanner from '../../src/index.js'; import { updateConsent, updateExecuted } from '../../src/lib/reducers.js'; import sampleTemplates from '../../example/src/js/sample-templates.js'; +const domainTypes = { perf: { title: 't', description: 'd', labels: { yes: 'y', no: 'n' }, fns: [() => { }] } }; + const init = () => { // Set up container for form document.body.innerHTML = `
`; @@ -29,11 +31,10 @@ describe(`Cookie banner > state > update/reducers`, () => { }; const Store = cookieBanner({ ...sampleTemplates, types }); - // Jest's toEqual ignored undefined-valued properties; node:assert's - // deepStrictEqual does not. Init runs the `executed` reducer, which sets - // types.test.executed to undefined (no consent yet), so reflect that here. + // Init runs the `executed` reducer, which sets types.test.executed to a boolean derived + // from consent — false here, since there is no consent yet. assert.deepStrictEqual(Store.getState().settings.types, { - test: { ...types.test, executed: undefined } + test: { ...types.test, executed: false } }); }); @@ -148,3 +149,22 @@ describe(`Cookie banner > state > update/reducers`, () => { }); }); }); + +describe(`Cookie banner > state > cookie domain`, () => { + beforeEach(() => { document.body.innerHTML = `
`; }); + + it('derives the domain once into state when not provided (host-only on localhost)', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes }); + assert.strictEqual(instance.getState().settings.domain, ''); + }); + + it('respects an explicitly provided domain rather than deriving one', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes, domain: '.example.com' }); + assert.strictEqual(instance.getState().settings.domain, '.example.com'); + }); + + it('treats an explicit empty-string domain as host-only, not as unset', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes, domain: '' }); + assert.strictEqual(instance.getState().settings.domain, ''); + }); +}); diff --git a/packages/cookie-banner/__tests__/unit/utils.test.js b/packages/cookie-banner/__tests__/unit/utils.test.js index 75f53b5e..d3c4311e 100644 --- a/packages/cookie-banner/__tests__/unit/utils.test.js +++ b/packages/cookie-banner/__tests__/unit/utils.test.js @@ -1,39 +1,65 @@ import { describe, it, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { groupValueReducer, removeSubdomain, extractFromCookie, broadcast, renderIframe, gtmSnippet } from '../../src/lib/utils.js'; +import { groupValueReducer, getRegistrableDomain, readCookie, extractFromCookie, broadcast, renderIframe, gtmSnippet } from '../../src/lib/utils.js'; import defaults from '../../src/lib/defaults.js'; import { EVENTS } from '../../src/lib/constants.js'; import { createStore } from '../../src/lib/store.js'; -describe('Cookie > Utils > removeSubdomain', () => { - it('should return the same vaule for a root domain', async () => { - assert.deepStrictEqual(removeSubdomain('stormid.com'), 'stormid.com'); +describe('Cookie > Utils > getRegistrableDomain', () => { + // Simulate the browser's cookie public-suffix rule: it refuses cookies on ICANN public + // suffixes but ACCEPTS them on PSL private-section hosts (azurewebsites.net, netlify.app). + const PUBLIC_SUFFIXES = new Set(['com', 'net', 'org', 'app', 'io', 'dev', 'uk', 'co.uk']); + const fakeCanSet = candidate => !PUBLIC_SUFFIXES.has(candidate); + + it('should return the same value for a root domain', async () => { + assert.deepStrictEqual(getRegistrableDomain('stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove www from a url', async () => { - assert.deepStrictEqual(removeSubdomain('www.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('www.stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove sub sub domains from a domain', async () => { - assert.deepStrictEqual(removeSubdomain('test.demo.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('test.demo.stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove subsub sub domains from a domain', async () => { - assert.deepStrictEqual(removeSubdomain('cookie.test.demo.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('cookie.test.demo.stormid.com', fakeCanSet), 'stormid.com'); + }); + + it('should handle URLs with multi dot tlds', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie.test.demo.stormid.co.uk', fakeCanSet), 'stormid.co.uk'); + }); + + it('should narrow back to the app host for azurewebsites.net (private suffix)', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie-test-wip.azurewebsites.net', fakeCanSet), 'cookie-test-wip.azurewebsites.net'); }); - it('should handle URLs with multi dot tdls', async () => { - assert.deepStrictEqual(removeSubdomain('cookie.test.demo.stormid.co.uk'), 'stormid.co.uk'); + it('should narrow back to the app host for netlify.app (private suffix)', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie-test-wip.netlify.app', fakeCanSet), 'cookie-test-wip.netlify.app'); }); - it('should handle azurewebsites.net as a multi dot tdl', async () => { - assert.deepStrictEqual(removeSubdomain('cookie-test-wip.azurewebsites.net'), 'cookie-test-wip.azurewebsites.net'); + it('should keep the app host for a subdomain of a private suffix', async () => { + assert.deepStrictEqual(getRegistrableDomain('sub.myapp.azurewebsites.net', fakeCanSet), 'myapp.azurewebsites.net'); }); - it('should handle netlify.app as a multi dot tdl', async () => { - assert.deepStrictEqual(removeSubdomain('cookie-test-wip.netlify.app'), 'cookie-test-wip.netlify.app'); + it('should return an empty string (host-only) for localhost', async () => { + assert.deepStrictEqual(getRegistrableDomain('localhost', fakeCanSet), ''); }); + it('should return an empty string (host-only) for an IP address', async () => { + assert.deepStrictEqual(getRegistrableDomain('192.168.0.1', fakeCanSet), ''); + }); +}); + +describe('Cookie > Utils > readCookie', () => { + it('should preserve base64 "=" padding in the cookie value', async () => { + const value = btoa(JSON.stringify({ consent: { performance: 1, ads: 0 } })); + assert.ok(value.endsWith('='), 'test fixture should carry base64 padding'); + document.cookie = `${defaults.name}=${value}`; + // round-trips to the original JSON despite the '=' padding chars in the value + assert.deepStrictEqual(readCookie(defaults), JSON.stringify({ consent: { performance: 1, ads: 0 } })); + }); }); describe('Cookie > Utils > groupValueReducer', () => { diff --git a/packages/cookie-banner/example/src/js/sample-templates.js b/packages/cookie-banner/example/src/js/sample-templates.js index 8fa77c6c..9b0ee732 100644 --- a/packages/cookie-banner/example/src/js/sample-templates.js +++ b/packages/cookie-banner/example/src/js/sample-templates.js @@ -31,7 +31,7 @@ export default { I am OK with this @@ -45,7 +45,7 @@ export default { No thank you diff --git a/packages/cookie-banner/src/lib/consent.js b/packages/cookie-banner/src/lib/consent.js index 867eea05..40881b34 100644 --- a/packages/cookie-banner/src/lib/consent.js +++ b/packages/cookie-banner/src/lib/consent.js @@ -4,7 +4,7 @@ export const applyEffects = state => { Object.keys(state.consent).forEach(key => { if (!state.settings.types[key]) return; if (state.settings.types[key].executed === true) return; - if (state.consent[key] && Boolean(state.consent[key]) && state.settings.types[key].fns) { + if (state.consent[key] && state.settings.types[key].fns) { state.settings.types[key].fns.forEach(fn => fn(state)); } }); @@ -18,7 +18,7 @@ export const apply = store => state => { Object.keys(state.settings.types).reduce((acc, type) => { acc[type] = { ...state.settings.types[type], - executed: state.settings.types[type].executed || (state.consent[type] && Boolean(state.consent[type])) + executed: state.settings.types[type].executed || Boolean(state.consent[type]) }; return acc; }, {}) diff --git a/packages/cookie-banner/src/lib/constants.js b/packages/cookie-banner/src/lib/constants.js index e8c37644..0c3e1098 100755 --- a/packages/cookie-banner/src/lib/constants.js +++ b/packages/cookie-banner/src/lib/constants.js @@ -7,4 +7,8 @@ export const EVENTS = { SHOW: 'banner.show', HIDE: 'banner.hide', CONSENT: 'banner.consent' +}; + +export const KEYS = { + TAB: 'Tab' }; \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/defaults.js b/packages/cookie-banner/src/lib/defaults.js index 6cd9e982..a0052c36 100755 --- a/packages/cookie-banner/src/lib/defaults.js +++ b/packages/cookie-banner/src/lib/defaults.js @@ -1,10 +1,8 @@ /* node:coverage disable */ -import { removeSubdomain } from './utils.js'; - export default { name: '.CookiePreferences', path: '/', - domain: window.location.hostname === 'localhost' ? '' : `.${removeSubdomain(window.location.hostname)}`, + domain: undefined, // auto-derived from the host once at init (see factory), unless overridden secure: true, samesite: 'strict', expiry: 365, @@ -32,8 +30,8 @@ export default { hideBannerOnFormPage: true, trapTab: false, savedMessage: 'Your settings have been saved.', - messageTemplate(model){ - return `
${model.settings.savedMessage}
` + messageTemplate(model){ + return `` }, bannerTemplate: null, formTemplate: null diff --git a/packages/cookie-banner/src/lib/factory.js b/packages/cookie-banner/src/lib/factory.js index c1733731..d05983a5 100644 --- a/packages/cookie-banner/src/lib/factory.js +++ b/packages/cookie-banner/src/lib/factory.js @@ -1,4 +1,4 @@ -import { cookiesEnabled, extractFromCookie, renderIframe, gtmSnippet, setGoogleConsent } from './utils.js'; +import { cookiesEnabled, extractFromCookie, renderIframe, gtmSnippet, setGoogleConsent, getRegistrableDomain } from './utils.js'; import { showBanner, initBanner, initForm, initBannerListeners, keyListener } from './ui.js'; import { necessary, apply } from './consent.js'; import { createStore } from './store.js'; @@ -10,6 +10,14 @@ export default settings => { console.warn('Missing required cookie banner and/or preferences form markup. Cookie banner not initialised.'); return; } + + // Derive the cookie domain once, here rather than at import: the probe runs only when a banner + // is actually initialised, and the result is memoised in state.settings.domain for reuse. + if (settings.domain === undefined) { + const registrable = getRegistrableDomain(); + settings.domain = registrable ? `.${registrable}` : ''; + } + const store = createStore(); const [ hasCookie, consent ] = extractFromCookie(settings); @@ -36,6 +44,7 @@ export default settings => { return { getState: store.getState, showBanner(cb) { + if (store.getState().bannerOpen) return; showBanner(store)(cb); initBannerListeners(store)(); }, diff --git a/packages/cookie-banner/src/lib/ui.js b/packages/cookie-banner/src/lib/ui.js index 2b55a634..82690ee7 100644 --- a/packages/cookie-banner/src/lib/ui.js +++ b/packages/cookie-banner/src/lib/ui.js @@ -1,12 +1,25 @@ import { writeCookie, groupValueReducer, deleteCookies, getFocusableChildren, broadcast, setGoogleConsent } from './utils.js'; -import { ACCEPTED_TRIGGERS, EVENTS } from './constants.js'; +import { ACCEPTED_TRIGGERS, EVENTS, KEYS } from './constants.js'; import { apply } from './consent.js'; import { updateConsent, updateBannerOpen, updateBanner } from './reducers.js'; +// Every template (banner, form, message) receives the same model: the full state, with the +// settings object also spread at the top level. So `model.classNames`/`model.policyURL` resolve +// (settings spread), `model.settings.*`/`model.consent` resolve, and any top-level state field the +// form/message templates previously received (they were passed raw state) is still present. +const templateModel = state => Object.assign({}, state, state.settings, { + settings: state.settings, + consent: state.consent +}); + export const initBanner = store => () => { const state = store.getState(); if (state.bannerOpen || (state.settings.hideBannerOnFormPage && document.querySelector(`.${state.settings.classNames.formContainer}`))) return; - document.body.firstElementChild.insertAdjacentHTML('beforebegin', state.settings.bannerTemplate(state.settings)); + const markup = state.settings.bannerTemplate(templateModel(state)); + // firstElementChild is null on a page whose body has no element children — fall back to + // inserting the banner as the body's first child rather than throwing. + if (document.body.firstElementChild) document.body.firstElementChild.insertAdjacentHTML('beforebegin', markup); + else document.body.insertAdjacentHTML('afterbegin', markup); store.update( updateBanner(state, { @@ -63,7 +76,6 @@ export const initBannerListeners = store => () => { store.update( updateConsent(state, consentObject), [ - deleteCookies, writeCookie, apply(store), removeBanner(store), @@ -86,6 +98,9 @@ export const initBannerListeners = store => () => { store.update( updateConsent(state, consentObject), [ + // Reject-all is the only path that clears cookies: withdrawing ALL consent is the + // one case where a blunt wipe is correct and no consent fns re-run to recreate them. + deleteCookies, writeCookie, removeBanner(store), initForm(store), @@ -115,7 +130,7 @@ const trapTab = state => event => { }; export const keyListener = store => event => { - if (store.getState().banner && event.keyCode === 9) trapTab(store.getState())(event); + if (store.getState().banner && event.key === KEYS.TAB) trapTab(store.getState())(event); }; const removeBanner = store => () => { @@ -142,12 +157,12 @@ export const initForm = store => () => { const formContainer = document.querySelector(`.${state.settings.classNames.formContainer}`); if (!formContainer) return; - formContainer.innerHTML = state.settings.formTemplate(suggestedConsent(state)); + formContainer.innerHTML = state.settings.formTemplate(templateModel(suggestedConsent(state))); const form = document.querySelector(`.${state.settings.classNames.form}`); const button = document.querySelector(`.${state.settings.classNames.submitBtn}`); const groups = [].slice.call(document.querySelectorAll(`.${state.settings.classNames.field}`)).reduce((groups, field) => { - const groupName = field.getAttribute('name').replace('privacy-', ''); + const groupName = field.getAttribute('name').replace(/^privacy-/, ''); if (groups[groupName]) groups[groupName].push(field); else groups[groupName] = [field]; return groups; @@ -188,7 +203,6 @@ export const initForm = store => () => { store.update( updateConsent(state, consentObject), [ - deleteCookies, writeCookie, apply(store), removeBanner(store), @@ -210,7 +224,7 @@ export const initForm = store => () => { }; export const renderMessage = button => state => { - button.insertAdjacentHTML('afterend', state.settings.messageTemplate(state)); + button.insertAdjacentHTML('afterend', state.settings.messageTemplate(templateModel(state))); button.setAttribute('disabled', 'disabled'); /* node:coverage ignore next */ window.setTimeout(() => { diff --git a/packages/cookie-banner/src/lib/utils.js b/packages/cookie-banner/src/lib/utils.js index 0252aef5..eb669ab8 100755 --- a/packages/cookie-banner/src/lib/utils.js +++ b/packages/cookie-banner/src/lib/utils.js @@ -15,7 +15,7 @@ export const cookiesEnabled = () => { export const writeCookie = state => { document.cookie = [ `${state.settings.name}=${btoa(JSON.stringify({ consent: state.consent }))};`, - `expires=${(new Date(new Date().getTime() + (state.settings.expiry*24*60*60*1000))).toGMTString()};`, + `expires=${(new Date(new Date().getTime() + (state.settings.expiry*24*60*60*1000))).toUTCString()};`, state.settings.path ? `path=${state.settings.path};` : '', state.settings.domain ? `domain=${state.settings.domain};` : '', state.settings.samesite ? `SameSite=${state.settings.samesite};` : '', @@ -24,13 +24,10 @@ export const writeCookie = state => { }; export const readCookie = settings => { - const cookies = document.cookie.split('; '); - for (let n = 0; n <= cookies.length; n++) { - if (!cookies[n]) return false; - const [ name, value ] = cookies[n].split('='); - if (name === settings.name) return window.atob(value); - } - return false; + const match = document.cookie.split('; ').find(cookie => cookie.split('=')[0] === settings.name); + if (!match) return false; + // slice from the first '=' rather than split('=')[1]: the base64 value can contain '=' padding. + return window.atob(match.slice(match.indexOf('=') + 1)); }; const updateCookie = (state, cookie) => document.cookie = [ @@ -87,22 +84,64 @@ export const groupValueReducer = (acc, input) => { return acc; }; -const firstTLDs = 'ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|be|bf|bg|bh|bi|bj|bm|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|cl|cm|cn|co|cr|cu|cv|cw|cx|cz|de|dj|dk|dm|do|dz|ec|ee|eg|es|et|eu|fi|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|im|in|io|iq|ir|is|it|je|jo|jp|kg|ki|km|kn|kp|kr|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|na|nc|ne|nf|ng|nl|no|nr|nu|nz|om|pa|pe|pf|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|yt'.split('|'); -const secondTLDs = 'netlify|azurewebsites|com|edu|gov|net|mil|org|nom|sch|caa|res|off|gob|int|tur|ip6|uri|urn|asn|act|nsw|qld|tas|vic|pro|biz|adm|adv|agr|arq|art|ato|bio|bmd|cim|cng|cnt|ecn|eco|emp|eng|esp|etc|eti|far|fnd|fot|fst|g12|ggf|imb|ind|inf|jor|jus|leg|lel|mat|med|mus|not|ntr|odo|ppg|psc|psi|qsl|rec|slg|srv|teo|tmp|trd|vet|zlg|web|ltd|sld|pol|fin|k12|lib|pri|aip|fie|eun|sci|prd|cci|pvt|mod|idv|rel|sex|gen|nic|abr|bas|cal|cam|emr|fvg|laz|lig|lom|mar|mol|pmn|pug|sar|sic|taa|tos|umb|vao|vda|ven|mie|北海道|和歌山|神奈川|鹿児島|ass|rep|tra|per|ngo|soc|grp|plc|its|air|and|bus|can|ddr|jfk|mad|nrw|nyc|ski|spy|tcm|ulm|usa|war|fhs|vgs|dep|eid|fet|fla|flå|gol|hof|hol|sel|vik|cri|iwi|ing|abo|fam|gok|gon|gop|gos|aid|atm|gsm|sos|elk|waw|est|aca|bar|cpa|jur|law|sec|plo|www|bir|cbg|jar|khv|msk|nov|nsk|ptz|rnd|spb|stv|tom|tsk|udm|vrn|cmw|kms|nkz|snz|pub|fhv|red|ens|nat|rns|rnu|bbs|tel|bel|kep|nhs|dni|fed|isa|nsn|gub|e12|tec|орг|обр|упр|alt|nis|jpn|mex|ath|iki|nid|gda|inc'.split('|'); +// PSL private-section suffixes: shared hosting domains where each subdomain is a separate site. +// Browsers do NOT block cookies on these (the cookie public-suffix rule uses the PSL's ICANN +// section only), so the domain probe below would over-broaden to the shared suffix and leak the +// consent cookie across every tenant. Narrowing back to the app host prevents that. Intentionally +// short — add hosting suffixes as needed. +export const PRIVATE_SUFFIXES = [ + 'azurewebsites.net', 'netlify.app', 'netlify.com', 'github.io', 'gitlab.io', + 'herokuapp.com', 'pages.dev', 'vercel.app', 'web.app', 'firebaseapp.com', 'appspot.com' +]; + +// Probe whether the browser will set a cookie scoped to `.candidate`. The browser refuses cookies +// on ICANN public suffixes (e.g. co.uk), so the broadest candidate that "sticks" is the +// registrable domain — no maintained TLD list required. +const canSetCookieOnDomain = candidate => { + const probe = '__cb_tld_probe'; + document.cookie = `${probe}=1; domain=.${candidate}; path=/; SameSite=Lax`; + const ok = document.cookie.indexOf(`${probe}=`) !== -1; + document.cookie = `${probe}=; domain=.${candidate}; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; + return ok; +}; -export const removeSubdomain = s => { - s = s.replace(/^www\./, ''); - let parts = s.split('.'); - - while (parts.length > 3) { - parts.shift(); - } +/** + * Registrable domain for the preferences cookie, so consent is shared across subdomains. + * + * @param hostname [String] host to derive from (defaults to the current location) + * @param canSet [Function] predicate testing whether the browser accepts a cookie on `.candidate` + * (injectable so the resolution logic is unit-testable without the PSL) + * + * @returns [String] registrable domain, or '' for localhost/IP/failure (a safe host-only default) + */ +export const getRegistrableDomain = (hostname = window.location.hostname, canSet = canSetCookieOnDomain) => { + try { + if (hostname === 'localhost' || /^\d+(\.\d+){3}$/.test(hostname)) return ''; + const labels = hostname.replace(/^www\./, '').split('.'); + if (labels.length < 2) return ''; + + let registrable = ''; + // Broadest (2 labels) → narrowest; first candidate the browser accepts wins. + for (let k = 2; k <= labels.length; k++) { + const candidate = labels.slice(-k).join('.'); + if (canSet(candidate)) { + registrable = candidate; + break; + } + } + if (!registrable) return ''; - if (parts.length === 3 && ((secondTLDs.indexOf(parts[1]) === -1) && firstTLDs.indexOf(parts[2]) === -1)) { - parts.shift(); - } + // If the browser accepted a known shared-hosting suffix, narrow to the app host so consent + // is not shared across tenants. + if (PRIVATE_SUFFIXES.indexOf(registrable) !== -1) { + const extra = labels.length - registrable.split('.').length - 1; + return extra < 0 ? '' : labels.slice(extra).join('.'); + } - return parts.join('.'); + return registrable; + } catch { + return ''; + } }; export const getFocusableChildren = node => [].slice.call(node.querySelectorAll(FOCUSABLE_ELEMENTS.join(','))).filter(el => el.offsetWidth > 0 || el.offsetHeight > 0); From e099b7221b05d7f3fad19e1fd0d56b10bb45f689 Mon Sep 17 00:00:00 2001 From: Mick Date: Wed, 26 Aug 2026 17:55:13 +0100 Subject: [PATCH 03/18] Auto-select a free dev-server port for all packages Dev servers use port 'auto'; the Playwright run picks a free port per package (memoised into an env var so runner and workers agree) and passes it to the spawned server via --port. Removes hardcoded ports and the redundant per-package playwright.rspack.config.js files. --- packages/autocomplete/playwright.config.js | 15 +-------------- .../tools/playwright.rspack.config.js | 11 ----------- packages/autocomplete/tools/rspack.config.js | 2 +- packages/cookie-banner/playwright.config.js | 15 +-------------- .../tools/playwright.rspack.config.js | 8 -------- packages/cookie-banner/tools/rspack.config.js | 2 +- packages/modal-gallery/playwright.config.js | 14 +------------- .../tools/playwright.rspack.config.js | 8 -------- packages/modal-gallery/tools/rspack.config.js | 2 +- packages/modal/playwright.config.js | 15 +-------------- packages/modal/tools/playwright.rspack.config.js | 8 -------- packages/modal/tools/rspack.config.js | 2 +- packages/outliner/playwright.config.js | 15 +-------------- .../outliner/tools/playwright.rspack.config.js | 8 -------- packages/outliner/tools/rspack.config.js | 2 +- packages/scroll-points/playwright.config.js | 15 +-------------- .../tools/playwright.rspack.config.js | 8 -------- packages/scroll-points/tools/rspack.config.js | 2 +- packages/scroll-spy/playwright.config.js | 15 +-------------- .../scroll-spy/tools/playwright.rspack.config.js | 8 -------- packages/scroll-spy/tools/rspack.config.js | 2 +- packages/skip/playwright.config.js | 15 +-------------- packages/skip/tools/playwright.rspack.config.js | 8 -------- packages/skip/tools/rspack.config.js | 2 +- packages/tabs/playwright.config.js | 15 +-------------- packages/tabs/tools/playwright.rspack.config.js | 8 -------- packages/tabs/tools/rspack.config.js | 2 +- packages/textarea/playwright.config.js | 15 +-------------- .../textarea/tools/playwright.rspack.config.js | 8 -------- packages/textarea/tools/rspack.config.js | 2 +- packages/toggle/playwright.config.js | 15 +-------------- packages/toggle/tools/playwright.rspack.config.js | 8 -------- packages/toggle/tools/rspack.config.js | 2 +- packages/validate/playwright.config.js | 15 +-------------- .../validate/tools/playwright.rspack.config.js | 8 -------- packages/validate/tools/rspack.config.js | 2 +- tools/playwright/config.base.js | 15 +++++++++++---- tools/playwright/free-port.js | 9 +++++++++ 38 files changed, 44 insertions(+), 282 deletions(-) delete mode 100644 packages/autocomplete/tools/playwright.rspack.config.js delete mode 100644 packages/cookie-banner/tools/playwright.rspack.config.js delete mode 100644 packages/modal-gallery/tools/playwright.rspack.config.js delete mode 100644 packages/modal/tools/playwright.rspack.config.js delete mode 100644 packages/outliner/tools/playwright.rspack.config.js delete mode 100644 packages/scroll-points/tools/playwright.rspack.config.js delete mode 100644 packages/scroll-spy/tools/playwright.rspack.config.js delete mode 100644 packages/skip/tools/playwright.rspack.config.js delete mode 100644 packages/tabs/tools/playwright.rspack.config.js delete mode 100644 packages/textarea/tools/playwright.rspack.config.js delete mode 100644 packages/toggle/tools/playwright.rspack.config.js delete mode 100644 packages/validate/tools/playwright.rspack.config.js create mode 100644 tools/playwright/free-port.js diff --git a/packages/autocomplete/playwright.config.js b/packages/autocomplete/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/autocomplete/playwright.config.js +++ b/packages/autocomplete/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/autocomplete/tools/playwright.rspack.config.js b/packages/autocomplete/tools/playwright.rspack.config.js deleted file mode 100644 index acf530ba..00000000 --- a/packages/autocomplete/tools/playwright.rspack.config.js +++ /dev/null @@ -1,11 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - //spread base devServer so mock /api/countries route (setupMiddlewares) - //is available under Playwright too, not just `npm run dev` - devServer: { - ...baseConfig.devServer, - port: 8095 - } -}; diff --git a/packages/autocomplete/tools/rspack.config.js b/packages/autocomplete/tools/rspack.config.js index 43057db2..262faf90 100644 --- a/packages/autocomplete/tools/rspack.config.js +++ b/packages/autocomplete/tools/rspack.config.js @@ -13,7 +13,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8095, + port: 'auto', //mock remote endpoint for async fetch example - same dev server //backs both `npm run dev` and the Playwright run, so this one route //serves the example page and e2e test diff --git a/packages/cookie-banner/playwright.config.js b/packages/cookie-banner/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/cookie-banner/playwright.config.js +++ b/packages/cookie-banner/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/cookie-banner/tools/playwright.rspack.config.js b/packages/cookie-banner/tools/playwright.rspack.config.js deleted file mode 100644 index 3a593117..00000000 --- a/packages/cookie-banner/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8091 - } -}; diff --git a/packages/cookie-banner/tools/rspack.config.js b/packages/cookie-banner/tools/rspack.config.js index 8b879763..cafed110 100644 --- a/packages/cookie-banner/tools/rspack.config.js +++ b/packages/cookie-banner/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/modal-gallery/playwright.config.js b/packages/modal-gallery/playwright.config.js index 3634054a..6f41eb80 100644 --- a/packages/modal-gallery/playwright.config.js +++ b/packages/modal-gallery/playwright.config.js @@ -1,15 +1,3 @@ const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/modal-gallery/tools/playwright.rspack.config.js b/packages/modal-gallery/tools/playwright.rspack.config.js deleted file mode 100644 index 2b261cb1..00000000 --- a/packages/modal-gallery/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8094 - } -}; diff --git a/packages/modal-gallery/tools/rspack.config.js b/packages/modal-gallery/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/modal-gallery/tools/rspack.config.js +++ b/packages/modal-gallery/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/modal/playwright.config.js b/packages/modal/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/modal/playwright.config.js +++ b/packages/modal/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/modal/tools/playwright.rspack.config.js b/packages/modal/tools/playwright.rspack.config.js deleted file mode 100644 index a07e3178..00000000 --- a/packages/modal/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8090 - } -}; diff --git a/packages/modal/tools/rspack.config.js b/packages/modal/tools/rspack.config.js index a9b4a91a..3d3c21e4 100644 --- a/packages/modal/tools/rspack.config.js +++ b/packages/modal/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/outliner/playwright.config.js b/packages/outliner/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/outliner/playwright.config.js +++ b/packages/outliner/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/outliner/tools/playwright.rspack.config.js b/packages/outliner/tools/playwright.rspack.config.js deleted file mode 100644 index e5582299..00000000 --- a/packages/outliner/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8082 - } -}; diff --git a/packages/outliner/tools/rspack.config.js b/packages/outliner/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/outliner/tools/rspack.config.js +++ b/packages/outliner/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/scroll-points/playwright.config.js b/packages/scroll-points/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/scroll-points/playwright.config.js +++ b/packages/scroll-points/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/scroll-points/tools/playwright.rspack.config.js b/packages/scroll-points/tools/playwright.rspack.config.js deleted file mode 100644 index 7baf39af..00000000 --- a/packages/scroll-points/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8086 - } -}; diff --git a/packages/scroll-points/tools/rspack.config.js b/packages/scroll-points/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/scroll-points/tools/rspack.config.js +++ b/packages/scroll-points/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/scroll-spy/playwright.config.js b/packages/scroll-spy/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/scroll-spy/playwright.config.js +++ b/packages/scroll-spy/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/scroll-spy/tools/playwright.rspack.config.js b/packages/scroll-spy/tools/playwright.rspack.config.js deleted file mode 100644 index 42137306..00000000 --- a/packages/scroll-spy/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8085 - } -}; diff --git a/packages/scroll-spy/tools/rspack.config.js b/packages/scroll-spy/tools/rspack.config.js index 432f6da1..b8eecdab 100644 --- a/packages/scroll-spy/tools/rspack.config.js +++ b/packages/scroll-spy/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/skip/playwright.config.js b/packages/skip/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/skip/playwright.config.js +++ b/packages/skip/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/skip/tools/playwright.rspack.config.js b/packages/skip/tools/playwright.rspack.config.js deleted file mode 100644 index af9892f4..00000000 --- a/packages/skip/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8084 - } -}; diff --git a/packages/skip/tools/rspack.config.js b/packages/skip/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/skip/tools/rspack.config.js +++ b/packages/skip/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/tabs/playwright.config.js b/packages/tabs/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/tabs/playwright.config.js +++ b/packages/tabs/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/tabs/tools/playwright.rspack.config.js b/packages/tabs/tools/playwright.rspack.config.js deleted file mode 100644 index e5c9c1f1..00000000 --- a/packages/tabs/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8087 - } -}; diff --git a/packages/tabs/tools/rspack.config.js b/packages/tabs/tools/rspack.config.js index a41a14d3..477edd28 100644 --- a/packages/tabs/tools/rspack.config.js +++ b/packages/tabs/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/textarea/playwright.config.js b/packages/textarea/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/textarea/playwright.config.js +++ b/packages/textarea/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/textarea/tools/playwright.rspack.config.js b/packages/textarea/tools/playwright.rspack.config.js deleted file mode 100644 index 62fe67ad..00000000 --- a/packages/textarea/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8083 - } -}; diff --git a/packages/textarea/tools/rspack.config.js b/packages/textarea/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/textarea/tools/rspack.config.js +++ b/packages/textarea/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/toggle/playwright.config.js b/packages/toggle/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/toggle/playwright.config.js +++ b/packages/toggle/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/toggle/tools/playwright.rspack.config.js b/packages/toggle/tools/playwright.rspack.config.js deleted file mode 100644 index eec59b0d..00000000 --- a/packages/toggle/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8089 - } -}; diff --git a/packages/toggle/tools/rspack.config.js b/packages/toggle/tools/rspack.config.js index c6643a68..182c0a6a 100644 --- a/packages/toggle/tools/rspack.config.js +++ b/packages/toggle/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/validate/playwright.config.js b/packages/validate/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/validate/playwright.config.js +++ b/packages/validate/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.config.js'); -module.exports = defineConfig({ - ...baseConfig, - use: { - ...baseConfig.use, - baseURL: `http://localhost:${server.devServer.port}/`, - }, - webServer: { - ...baseConfig.webServer, - url: `http://localhost:${server.devServer.port}/`, - }, -}); \ No newline at end of file +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/validate/tools/playwright.rspack.config.js b/packages/validate/tools/playwright.rspack.config.js deleted file mode 100644 index a9c1d00c..00000000 --- a/packages/validate/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8092 - } -}; diff --git a/packages/validate/tools/rspack.config.js b/packages/validate/tools/rspack.config.js index ac7de931..c2feacde 100644 --- a/packages/validate/tools/rspack.config.js +++ b/packages/validate/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/tools/playwright/config.base.js b/tools/playwright/config.base.js index 3bcfb5b0..e4fcd2c2 100644 --- a/tools/playwright/config.base.js +++ b/tools/playwright/config.base.js @@ -1,6 +1,13 @@ const { devices } = require('@playwright/test'); +const freePort = require('./free-port'); -// CURRENT MAX PORT NUMBER IN USE: 8095 +// A free port is picked once per `playwright test` run, then memoised into an env var: Playwright +// reloads this config in every worker process, and the port must stay identical across the runner +// (which starts the dev server) and the workers (which navigate to baseURL). Workers inherit the +// env the runner set before spawning them. Per package = per process = its own port, so concurrent +// `lerna run test` never collides, and no ports are hardcoded. The dev server the webServer command +// spawns is handed the same port via `--port`. +const port = process.env.PLAYWRIGHT_DEV_PORT || (process.env.PLAYWRIGHT_DEV_PORT = String(freePort())); module.exports = { testDir: './__tests__/playwright', @@ -14,7 +21,7 @@ module.exports = { timeout: 10_000, }, use: { - baseURL: 'http://localhost:8081', + baseURL: `http://localhost:${port}`, trace: 'on-first-retry', }, projects: [ @@ -48,8 +55,8 @@ module.exports = { }, ], webServer: { - command: 'rspack serve --config tools/playwright.rspack.config.js', - url: 'http://localhost:8081', + command: `rspack serve --config tools/rspack.config.js --port ${port}`, + url: `http://localhost:${port}`, reuseExistingServer: !process.env.CI, }, }; diff --git a/tools/playwright/free-port.js b/tools/playwright/free-port.js new file mode 100644 index 00000000..05fb1017 --- /dev/null +++ b/tools/playwright/free-port.js @@ -0,0 +1,9 @@ +const { execFileSync } = require('child_process'); + +// Playwright loads playwright.config.js synchronously and does not await a Promise export, so a free +// port must be resolved synchronously here. Node has no synchronous TCP bind, so run a short-lived +// child that binds port 0 (OS-assigned free port), reports it, and exits. +module.exports = function freePort() { + const finder = "const s=require('net').createServer();s.listen(0,()=>{process.stdout.write(String(s.address().port));s.close();});"; + return Number(execFileSync(process.execPath, ['-e', finder], { encoding: 'utf8' }).trim()); +}; From 75d59021dd327d6a9fd2b513b2374a2eb3b72306 Mon Sep 17 00:00:00 2001 From: Mick Date: Wed, 26 Aug 2026 18:51:42 +0100 Subject: [PATCH 04/18] Fix modal correctness, a11y, lifecycle and docs --- packages/modal/README.md | 21 +-- .../__tests__/playwright/playwright.spec.js | 9 +- packages/modal/__tests__/unit/init.test.js | 92 +++++++++++- packages/modal/example/src/attribute.html | 4 +- packages/modal/example/src/index.html | 4 +- packages/modal/example/src/start-open.html | 4 +- packages/modal/src/index.js | 29 ++-- packages/modal/src/lib/dom.js | 141 ++++++++++++------ packages/modal/src/lib/factory.js | 19 ++- packages/modal/src/lib/utils.js | 20 ++- 10 files changed, 259 insertions(+), 84 deletions(-) diff --git a/packages/modal/README.md b/packages/modal/README.md index 2631e895..24f189dc 100644 --- a/packages/modal/README.md +++ b/packages/modal/README.md @@ -10,9 +10,9 @@ Example implementations of specific types of modal are available for reference a Create a modal dialog and button(s) to toggle in HTML ``` - +