diff --git a/README.md b/README.md index 38b5a4f..894fa9b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ Interface languages: English, Russian, Kazakh (follows the browser language). ## Keyboard shortcuts -- `Alt+Shift+X` - open popup -- `Alt+Shift+P` - toggle profiler cookie +- `Alt+Shift+K` - open popup +- `Alt+Shift+F` - toggle profiler cookie - `Alt+Shift+D` - toggle debugger cookie + +(`Alt+Shift+P` and `Alt+Shift+X` are reserved by Chrome for tab groups.) - toggle trace cookie - no default shortcut Firefox: `about:addons` → gear → "Manage Extension Shortcuts". diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 58d3a94..cce8b6d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -118,5 +118,18 @@ }, "optSaveError": { "message": "There were some errors during save, please try again." + }, + "optInvalidName": { + "message": "Invalid cookie name: \"$1\"." + }, + "optDuplicateName": { + "message": "The same cookie name is used twice: \"$1\"." + }, + "optInvalidValue": { + "message": "Invalid cookie value: \"$1\" (spaces, quotes, commas, semicolons and backslashes are not allowed)." + }, + "langCode": { + "message": "en", + "description": "Language code of this translation for the lang attribute" } } diff --git a/_locales/kk/messages.json b/_locales/kk/messages.json index d21e7e9..9e062fe 100644 --- a/_locales/kk/messages.json +++ b/_locales/kk/messages.json @@ -118,5 +118,18 @@ }, "optSaveError": { "message": "Сақтау кезінде қате орын алды, қайталап көріңіз." + }, + "optInvalidName": { + "message": "Cookie атауы жарамсыз: «$1»." + }, + "optDuplicateName": { + "message": "Бір cookie атауы екі рет көрсетілген: «$1»." + }, + "optInvalidValue": { + "message": "Cookie мәні жарамсыз: «$1» (бос орын, тырнақша, үтір, нүктелі үтір және кері қиғаш сызық қолдануға болмайды)." + }, + "langCode": { + "message": "kk", + "description": "Language code of this translation for the lang attribute" } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index f217824..d1c7c45 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -118,5 +118,18 @@ }, "optSaveError": { "message": "При сохранении произошла ошибка, попробуйте ещё раз." + }, + "optInvalidName": { + "message": "Недопустимое имя cookie: «$1»." + }, + "optDuplicateName": { + "message": "Одно и то же имя cookie указано дважды: «$1»." + }, + "optInvalidValue": { + "message": "Недопустимое значение cookie: «$1» (нельзя использовать пробелы, кавычки, запятые, точки с запятой и обратную косую черту)." + }, + "langCode": { + "message": "ru", + "description": "Language code of this translation for the lang attribute" } } diff --git a/background.js b/background.js index 8f5bd8f..2140302 100644 --- a/background.js +++ b/background.js @@ -192,5 +192,6 @@ api.storage.onChanged.addListener(onSettingsChanged); api.commands.onCommand.addListener(onCommand); api.runtime.onInstalled.addListener(onInstalled); api.permissions.onAdded.addListener(updateActiveTabs); +api.permissions.onRemoved.addListener(updateActiveTabs); updateActiveTabs(); diff --git a/common.js b/common.js index 56c7e3d..08428b0 100644 --- a/common.js +++ b/common.js @@ -40,12 +40,25 @@ function t(key, ...substitutions) { // Translates elements with data-i18n (text), data-i18n-placeholder and data-i18n-title attributes function localizePage(root) { - root = root || document; + if (!root) { + root = document; + // Language of the translation actually used (stored in messages.json) + document.documentElement.lang = t('langCode'); + } root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); }); root.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nPlaceholder); }); root.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(el.dataset.i18nTitle); }); } +// RFC 6265: cookie-name is a token, cookie-value has no spaces, quotes, commas, semicolons, backslashes +function isValidCookieName(name) { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name); +} + +function isValidCookieValue(value) { + return /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/.test(value); +} + function getIdePreset(key) { return IDE_PRESETS.find(preset => preset.key === key) || null; } diff --git a/manifest.json b/manifest.json index ed4b849..899e19c 100644 --- a/manifest.json +++ b/manifest.json @@ -38,13 +38,13 @@ "commands": { "_execute_action": { "suggested_key": { - "default": "Alt+Shift+X" + "default": "Alt+Shift+K" }, "description": "Open popup" }, "toggle-profile": { "suggested_key": { - "default": "Alt+Shift+P" + "default": "Alt+Shift+F" }, "description": "Toggle profiler cookie (XDEBUG_PROFILE)" }, diff --git a/options.css b/options.css index affa549..f8f871d 100644 --- a/options.css +++ b/options.css @@ -1,4 +1,6 @@ :root { + /* Default colors and form controls follow the browser light/dark theme */ + color-scheme: light dark; --gutter-size: 10px; --input-height: 2rem; --label-width: 26rem; @@ -51,7 +53,7 @@ input[type=radio], .message-container { position: relative; - height: 3.1rem; + min-height: 3.1rem; } .message { @@ -127,3 +129,7 @@ input[type=radio], font-size: 0.85rem; opacity: 0.75; } + +.invalid { + outline: 2px solid #c0392b; +} diff --git a/options.js b/options.js index aa82e61..9cda587 100644 --- a/options.js +++ b/options.js @@ -11,19 +11,41 @@ async function saveOptions(e) { cookie_XDEBUG_PROFILE_on_color: onColor ? onColor.value : DEFAULT_ON_COLOR, cookie_XDEBUG_PROFILE_off_color: offColor ? offColor.value : DEFAULT_OFF_COLOR }; + let errors = []; + let names = {}; + document.querySelectorAll(".invalid").forEach(el => el.classList.remove("invalid")); for (let trigger of TRIGGERS) { - options[trigger.key] = document.querySelector(".trigger-name[data-key=" + trigger.key + "]").value.trim(); + let nameInput = document.querySelector(".trigger-name[data-key=" + trigger.key + "]"); + options[trigger.key] = nameInput.value.trim(); + let effectiveName = options[trigger.key] || trigger.defaultName; + if (!isValidCookieName(effectiveName)) { + errors.push(t('optInvalidName', effectiveName)); + nameInput.classList.add("invalid"); + } else if (names[effectiveName]) { + errors.push(t('optDuplicateName', effectiveName)); + nameInput.classList.add("invalid"); + } + names[effectiveName] = true; let value = document.querySelector(".trigger-value[data-key=" + trigger.key + "]").value.trim(); let ide = document.querySelector(".trigger-ide[data-key=" + trigger.key + "]"); if (trigger.ide && ide.value) { // Preset selected, custom input is ignored value = ide.value; } + if (!isValidCookieValue(value)) { + errors.push(t('optInvalidValue', value)); + document.querySelector(".trigger-value[data-key=" + trigger.key + "]").classList.add("invalid"); + } options[trigger.key + '_value'] = value; if (trigger.ide) { options[IDE_CHOSEN_KEY] = true; } } + if (errors.length) { + console.debug(OPTIONS_LOG_PREFIX, 'not saved, invalid values:', errors); + showError(errors.join(' ')); + return; + } console.debug(OPTIONS_LOG_PREFIX, 'saving', options); let saved = api.storage.sync.set(options); @@ -117,10 +139,16 @@ function showSuccess() { }, 2000); } -function showError(error) { - console.error(OPTIONS_LOG_PREFIX, 'save failed', error); - let messageClass = document.querySelector(".saved-error").classList; - messageClass.add(visibilityClass); +// message - validation text; without it (storage error) the generic text is shown +function showError(message) { + let element = document.querySelector(".saved-error"); + if (typeof message === 'string') { + element.textContent = message; + } else { + console.error(OPTIONS_LOG_PREFIX, 'save failed', message); + element.textContent = t('optSaveError'); + } + element.classList.add(visibilityClass); } function hideMessages() { diff --git a/popup.css b/popup.css index 56e32b9..bf502b8 100644 --- a/popup.css +++ b/popup.css @@ -1,4 +1,5 @@ :root { + color-scheme: light dark; --text: #15141a; --muted: #6b6a70; --bg: #ffffff; diff --git a/popup.js b/popup.js index 5b25561..ea3b427 100644 --- a/popup.js +++ b/popup.js @@ -118,6 +118,10 @@ async function onSiteIdeChange(e) { async function onSiteIdeCustomSubmit(e) { e.preventDefault(); let value = document.querySelector('.site-ide-custom').value.trim(); + if (!isValidCookieValue(value)) { + showError(t('optInvalidValue', value)); + return; + } await applySiteIdeKey(value); } diff --git a/store/listing-en.md b/store/listing-en.md index 63f3886..3a2bb1d 100644 --- a/store/listing-en.md +++ b/store/listing-en.md @@ -20,8 +20,8 @@ Features: • IDE presets: VS Code (vsc), PhpStorm, NetBeans, Eclipse, MacGDBp or a custom IDE key • Own IDE key for a site (e.g. projects behind a DBGp proxy) • Custom cookie names and values (xdebug.trigger_value) -• Keyboard shortcuts: Alt+Shift+P — profiler, Alt+Shift+D — debugger, Alt+Shift+X — popup -• Works with Firefox containers and private windows +• Keyboard shortcuts: Alt+Shift+F — profiler, Alt+Shift+D — debugger, Alt+Shift+K — popup +• Works with Firefox containers, private windows and Chrome incognito (after you allow the extension there in the browser add-on settings) • English, Russian and Kazakh interface Server setup (Xdebug 3): diff --git a/store/listing-ru.md b/store/listing-ru.md index c582702..e1ac4e2 100644 --- a/store/listing-ru.md +++ b/store/listing-ru.md @@ -20,8 +20,8 @@ Xdebug Cookies: Debug, Profile, Trace • Готовые IDE: VS Code (vsc), PhpStorm, NetBeans, Eclipse, MacGDBp или свой IDE key • Свой IDE key для отдельного сайта (например, для проектов за DBGp-прокси) • Свои имена и значения cookie (xdebug.trigger_value) -• Горячие клавиши: Alt+Shift+P — профайлер, Alt+Shift+D — отладчик, Alt+Shift+X — окно -• Работает с контейнерами Firefox и приватными окнами +• Горячие клавиши: Alt+Shift+F — профайлер, Alt+Shift+D — отладчик, Alt+Shift+K — окно +• Работает с контейнерами Firefox, приватными окнами и режимом инкогнито Chrome (после того как вы разрешите там расширение в настройках дополнений браузера) • Интерфейс на английском, русском и казахском Настройка сервера (Xdebug 3): diff --git a/tests/chrome.test.js b/tests/chrome.test.js index 1b3622d..0f3187a 100644 --- a/tests/chrome.test.js +++ b/tests/chrome.test.js @@ -73,6 +73,15 @@ describe('Chromium', () => { assert.ok(urls.some(u => u.includes('options')), 'pages: ' + urls.join(', ')); }); + it('keyboard shortcuts are assigned (not reserved by Chrome)', async () => { + const commands = await worker.evaluate(() => api.commands.getAll()); + const shortcuts = Object.fromEntries(commands.map(c => [c.name, c.shortcut])); + console.log('[test] shortcuts', shortcuts); + assert.equal(shortcuts._execute_action, 'Alt+Shift+K'); + assert.equal(shortcuts['toggle-profile'], 'Alt+Shift+F'); + assert.equal(shortcuts['toggle-debug'], 'Alt+Shift+D'); + }); + it('has host permission and shows three triggers', async () => { assert.ok(await popup.isHidden('.no-permission')); const rows = await popup.$$eval('.trigger', els => els.map(e => e.textContent.replace(/\s+/g, ' ').trim())); @@ -189,6 +198,46 @@ describe('Chromium', () => { await options.close(); }); + it('options: invalid and duplicate cookie names are not saved', async () => { + const options = await context.newPage(); + await options.goto(`chrome-extension://${extensionId}/options.html`); + await options.waitForSelector('.trigger-name'); + const stored = () => worker.evaluate(() => api.storage.sync.get(null)); + const before = await stored(); + + await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG TRACE'); + await options.click('button[type=submit]'); + await eventually(async () => assert.match(await options.textContent('.saved-error'), /XDEBUG TRACE/)); + assert.ok(await options.isVisible('.saved-error')); + assert.equal(await options.getAttribute('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'class'), 'trigger-name invalid'); + + await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG_PROFILE'); + await options.click('button[type=submit]'); + await eventually(async () => assert.match(await options.textContent('.saved-error'), /twice: "XDEBUG_PROFILE"/)); + + await options.fill('.trigger-name[data-key=cookie_XDEBUG_TRACE]', 'XDEBUG_TRACE'); + await options.fill('.trigger-value[data-key=cookie_XDEBUG_PROFILE]', 'a;b'); + await options.click('button[type=submit]'); + await eventually(async () => assert.match(await options.textContent('.saved-error'), /"a;b"/)); + assert.deepEqual(await stored(), before, 'invalid options must not be saved'); + + await options.fill('.trigger-value[data-key=cookie_XDEBUG_PROFILE]', 'Y'); + await options.click('button[type=submit]'); + await eventually(async () => assert.ok(await options.isVisible('.saved-successfully'))); + assert.equal(await options.$$eval('.invalid', els => els.length), 0); + await options.close(); + }); + + it('popup: invalid custom IDE key is rejected', async () => { + await openPopup(); + await popup.click('.trigger-ide'); + await popup.selectOption('.site-ide', '__custom__'); + await popup.fill('.site-ide-custom', 'my key'); + await popup.click('.site-ide-custom-row button'); + await eventually(async () => assert.match(await popup.textContent('.error'), /"my key"/)); + assert.equal(await worker.evaluate(() => getSiteIdeKey('app.example.co.uk')), ''); + }); + it('shows unsupported message on browser pages', async () => { // Without the "tabs" permission non-http tabs can't be found by URL, so the site tab is reused await site.goto('about:blank'); @@ -235,3 +284,54 @@ for (const [lang, debugLabel, settingsLabel] of [['ru', 'Отладка', 'На }); }); } + +describe('Chromium incognito', () => { + let server, context, extensionId; + + before(async () => { + server = await startServer(); + ({ context, extensionId } = await launch()); + }); + + after(async () => { + await context.close(); + server.close(); + }); + + it('works in incognito after the user allows it (own cookie store)', async () => { + // Same switches as on chrome://extensions: developer mode (unpacked addon) and "Allow in Incognito" + const page = await context.newPage(); + await page.goto('chrome://extensions/'); + const info = id => page.evaluate(i => new Promise(r => chrome.developerPrivate.getExtensionInfo(i, r)), id); + assert.equal((await info(extensionId)).incognitoAccess.isActive, false, 'not allowed in incognito by default'); + await page.evaluate(() => new Promise(r => chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, r))); + await page.evaluate(id => new Promise(r => chrome.developerPrivate.updateExtensionConfiguration( + { extensionId: id, incognitoAccess: true }, r)), extensionId); + await eventually(async () => assert.equal((await info(extensionId)).state, 'ENABLED'), 10000); + + // The addon is reloaded, its functions are called from an extension page + const extPage = await context.newPage(); + await eventually(() => extPage.goto(`chrome-extension://${extensionId}/options.html`), 10000); + const result = await extPage.evaluate(async site => { + const win = await api.windows.create({ incognito: true, url: site }); + let tab; + for (let i = 0; i < 50; i++) { + tab = (await api.tabs.query({ windowId: win.id }))[0]; + if (tab.status === 'complete' && tab.url) { + break; + } + await new Promise(r => setTimeout(r, 200)); + } + const count = async storeId => (await api.cookies.getAll({ name: 'XDEBUG_PROFILE', storeId: storeId })).length; + const out = { incognito: tab.incognito, store: await getStoreId(tab) }; + out.enabled = await toggleTrigger(tab, 'profile'); + out.inIncognito = await count('1'); + out.inNormal = await count('0'); + out.disabled = await toggleTrigger(tab, 'profile'); + out.afterOff = await count('1'); + await api.windows.remove(win.id); + return out; + }, SITE); + assert.deepEqual(result, { incognito: true, store: '1', enabled: true, inIncognito: 1, inNormal: 0, disabled: false, afterOff: 0 }); + }); +}); diff --git a/tests/firefox.test.js b/tests/firefox.test.js index d581461..dcaabf3 100644 --- a/tests/firefox.test.js +++ b/tests/firefox.test.js @@ -70,9 +70,9 @@ describe('Firefox', () => { // from the browser (chrome) context with the system principal async function openExtensionPage(url) { await driver.setContext(firefox.Context.CHROME); + // In the chrome context `window` is the browser window of the current WebDriver tab await driver.executeScript(` - const win = Services.wm.getMostRecentWindow('navigator:browser'); - win.gBrowser.selectedBrowser.fixupAndLoadURIString(arguments[0], { + window.gBrowser.selectedBrowser.fixupAndLoadURIString(arguments[0], { triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal() });`, url); await driver.setContext(firefox.Context.CONTENT); @@ -102,7 +102,9 @@ describe('Firefox', () => { .setPreference('dom.security.https_first', false) .setPreference('dom.security.https_first_schemeless', false) // Direct connection: test hosts must not go through a system proxy - .setPreference('network.proxy.type', 0); + .setPreference('network.proxy.type', 0) + // Containers for the container tab test + .setPreference('privacy.userContext.enabled', true); // Custom Firefox / geckodriver, otherwise they are found by Selenium Manager if (process.env.FIREFOX_PATH) { options.setBinary(process.env.FIREFOX_PATH); @@ -156,6 +158,15 @@ describe('Firefox', () => { assert.ok(!await isVisible('.no-permission')); }); + it('keyboard shortcuts are assigned', async () => { + const commands = await inExtension('browser.commands.getAll'); + const shortcuts = Object.fromEntries(commands.map(c => [c.name, c.shortcut])); + console.log('[test] shortcuts', shortcuts); + assert.equal(shortcuts._execute_action, 'Alt+Shift+K'); + assert.equal(shortcuts['toggle-profile'], 'Alt+Shift+F'); + assert.equal(shortcuts['toggle-debug'], 'Alt+Shift+D'); + }); + it('shows three triggers', async () => { const labels = []; for (const el of await driver.findElements(By.css('.trigger-label'))) { @@ -224,6 +235,117 @@ describe('Firefox', () => { assert.ok(!await isVisible('.welcome-row')); }); + // Opens a tab from the browser (chrome) context: in a container or in a new private window + async function openSpecialTab(url, kind) { + await driver.setContext(firefox.Context.CHROME); + await driver.executeScript(` + const principal = Services.scriptSecurityManager.getSystemPrincipal(); + if (arguments[1] === 'private') { + const privateWin = window.OpenBrowserWindow({ private: true }); + // The window is ready for navigation only after its delayed startup, + // otherwise the initial about:privatebrowsing page may replace the site + privateWin.addEventListener('load', () => { + privateWin.delayedStartupPromise.then(() => { + privateWin.gBrowser.selectedBrowser.fixupAndLoadURIString(arguments[0], { triggeringPrincipal: principal }); + }); + }, { once: true }); + } else { + window.gBrowser.addTab(arguments[0], { userContextId: 1, triggeringPrincipal: principal }); + }`, url, kind); + await driver.setContext(firefox.Context.CONTENT); + } + + it('works in a container tab (own cookie store)', async () => { + await openSpecialTab(SITE, 'container'); + await openExtensionPage(BASE + 'options.html'); + const tab = await eventually(async () => { + const tabs = await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-container-1' }); + assert.equal(tabs.length, 1); + assert.equal(tabs[0].status, 'complete'); + return tabs[0]; + }, 10000); + await openPopup(tab.id); + await driver.findElement(By.css('.trigger input[data-trigger=profile]')).click(); + const inStore = async storeId => (await inExtension('browser.cookies.getAll', + { name: 'XDEBUG_PROFILE', storeId: storeId, firstPartyDomain: null })).length; + await eventually(async () => assert.equal(await inStore('firefox-container-1'), 1)); + assert.equal(await inStore('firefox-default'), 0, 'cookie must not leak to the default store'); + await openPopup(tab.id); + await driver.findElement(By.css('.trigger input[data-trigger=profile]')).click(); + await eventually(async () => assert.equal(await inStore('firefox-container-1'), 0)); + await inExtension('browser.tabs.remove', tab.id); + }); + + // Same as the user switch "Run in Private Windows" in about:addons: permission + addon reload + async function allowInPrivateWindows() { + await driver.setContext(firefox.Context.CHROME); + await driver.executeAsyncScript(` + const done = arguments[arguments.length - 1]; + const { ExtensionPermissions } = ChromeUtils.importESModule('resource://gre/modules/ExtensionPermissions.sys.mjs'); + const { AddonManager } = ChromeUtils.importESModule('resource://gre/modules/AddonManager.sys.mjs'); + ExtensionPermissions.add(arguments[0], { permissions: ['internal:privateBrowsingAllowed'], origins: [] }) + .then(() => AddonManager.getAddonByID(arguments[0])) + .then(addon => addon.reload()) + .then(() => done('ok'), e => done(String(e)));`, ADDON_ID); + await driver.setContext(firefox.Context.CONTENT); + } + + it('is not active in private windows until the user allows it', async () => { + await openSpecialTab(SITE, 'private'); + await openExtensionPage(BASE + 'options.html'); + // Wait until the private tab exists for the browser, then make sure the addon does not see it + await new Promise(resolve => setTimeout(resolve, 2000)); + assert.equal((await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-private' })).length, 0); + assert.equal(await inExtension('browser.extension.isAllowedIncognitoAccess'), false); + }); + + it('works in a private window when allowed', async () => { + await allowInPrivateWindows(); + // The reload closes extension pages, including the tab used for them + await driver.switchTo().window(siteHandle); + await driver.switchTo().newWindow('tab'); + popupHandle = await driver.getWindowHandle(); + await eventually(async () => { + await openExtensionPage(BASE + 'options.html'); + assert.equal(await inExtension('hasHostPermission'), true); + }, 10000); + assert.equal(await inExtension('browser.extension.isAllowedIncognitoAccess'), true); + await openExtensionPage(BASE + 'options.html'); + const before = await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-private' }); + console.log('[test] private tabs opened before the permission, url:', before.map(t => t.url)); + // A private window opened after the permission is granted (the usual user flow) + await openSpecialTab(SITE, 'private'); + await openExtensionPage(BASE + 'options.html'); + const tab = await eventually(async () => { + const tabs = (await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-private' })) + .filter(t => t.url === SITE && t.status === 'complete'); + // Both private windows (opened before and after the permission) show the site + assert.equal(tabs.length, 2); + return tabs[1]; + }, 10000); + assert.equal(tab.incognito, true); + await openPopup(tab.id); + await driver.findElement(By.css('.trigger input[data-trigger=profile]')).click(); + const inStore = async storeId => (await inExtension('browser.cookies.getAll', + { name: 'XDEBUG_PROFILE', storeId: storeId, firstPartyDomain: null })).length; + await eventually(async () => assert.equal(await inStore('firefox-private'), 1)); + assert.equal(await inStore('firefox-default'), 0, 'cookie must not leak to the default store'); + await inExtension('browser.windows.remove', tab.windowId); + }); + + it('removes cookie of an IPv6 host', async () => { + await openExtensionPage(BASE + 'options.html'); + // Cookie API does not need a loaded page, the tab object is enough + const fakeTab = { id: -1, url: 'http://[::1]:8765/' }; + assert.equal(await inExtension('toggleTrigger', fakeTab, 'profile'), true); + const list = async () => (await inExtension('browser.cookies.getAll', + { name: 'XDEBUG_PROFILE', firstPartyDomain: null })).map(c => c.domain); + console.log('[test] IPv6 cookie domain:', await list()); + assert.equal((await list()).length, 1); + assert.equal(await inExtension('toggleTrigger', fakeTab, 'profile'), false); + assert.deepEqual(await list(), []); + }); + it('shows unsupported message on browser pages', async () => { await driver.switchTo().window(siteHandle); await driver.get('about:blank');