diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a2692d..e5420fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: cache: npm - run: npm ci - run: npm run lint + - run: npm run test:unit - run: npm run build chrome: diff --git a/README.md b/README.md index 894fa9b..e7efba2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # XDEBUG_PROFILE (cookie trigger) Firefox addon for setting XDEBUG_PROFILE cookie -Now works in **Firefox 140+** and **Chrome / Chromium 121+** (one Manifest V3 codebase). +Now works in **Firefox 140+**, **Firefox for Android 142+** and **Chrome / Chromium 121+** (one Manifest V3 codebase). +On Android there are no keyboard shortcuts (the browser has no commands API); every browser API is +checked before use, so missing APIs only switch off the related feature. The toolbar popup toggles Xdebug trigger cookies for the current site: @@ -46,6 +48,7 @@ Package: `npm install && npm run build` → `web-ext-artifacts/*.zip` (works for ```sh npm install npm run lint # web-ext lint +npm run test:unit # no browser: API helpers, Firefox for Android-like environment npm run test:chrome # Playwright, Chromium (CHROMIUM_PATH can point to a custom binary) npm run test:firefox # Selenium + geckodriver, needs Firefox # (FIREFOX_PATH / GECKODRIVER_PATH can point to custom binaries) diff --git a/background.js b/background.js index 2140302..d1620b1 100644 --- a/background.js +++ b/background.js @@ -57,10 +57,10 @@ async function renderIcon(tab) { log('updateIcon: tab', tab.id, tab.url, 'enabled:', badge || '-'); try { - await api.action.setIcon({ path: getIcons(enabled.length ? colors.on : colors.off), tabId: tab.id }); - await api.action.setTitle({ title: title, tabId: tab.id }); - await api.action.setBadgeText({ text: badge, tabId: tab.id }); - await api.action.setBadgeBackgroundColor({ color: '#c0392b', tabId: tab.id }); + await callApi('action.setIcon', [{ path: getIcons(enabled.length ? colors.on : colors.off), tabId: tab.id }]); + await callApi('action.setTitle', [{ title: title, tabId: tab.id }]); + await callApi('action.setBadgeText', [{ text: badge, tabId: tab.id }]); + await callApi('action.setBadgeBackgroundColor', [{ color: '#c0392b', tabId: tab.id }]); } catch (e) { // The tab may be closed between query and update logError('updateIcon failed', e); @@ -69,7 +69,7 @@ async function renderIcon(tab) { // Service worker has no persistent state, so active tabs are always queried async function updateActiveTabs() { - let tabs = await api.tabs.query({ active: true }); + let tabs = await callApi('tabs.query', [{ active: true }], []); for (let tab of tabs) { await updateIcon(tab); } @@ -78,7 +78,7 @@ async function updateActiveTabs() { // Removes cookies with the old name after it has been changed in options, // otherwise they stay in the browser and keep triggering Xdebug. async function removeCookiesByName(name, value) { - let stores = await api.cookies.getAllCookieStores(); + let stores = await callApi('cookies.getAllCookieStores', [], []); for (let store of stores) { let query = { name: name, storeId: store.id }; if (IS_FIREFOX) { @@ -86,7 +86,7 @@ async function removeCookiesByName(name, value) { } let cookies = []; try { - cookies = await api.cookies.getAll(query); + cookies = await requireApi('cookies.getAll', [query]); } catch (e) { logError('removeCookiesByName: getAll failed for store', store.id, e); continue; @@ -106,7 +106,7 @@ async function removeCookiesByName(name, value) { async function onSettingsChanged(changes, area) { log('settings changed in', area, changes); - if (area === 'sync') { + if (area === SETTINGS_AREA) { let triggers = await getTriggerSettings(); for (let trigger of TRIGGERS) { let nameChange = changes[trigger.key]; @@ -135,7 +135,7 @@ async function onCookieChanged(changeInfo) { } log('cookie changed:', changeInfo.cookie.name, changeInfo.cookie.domain, 'removed:', changeInfo.removed, 'cause:', changeInfo.cause); let domain = changeInfo.cookie.domain.replace(/^\./, ''); - let tabs = await api.tabs.query({}); + let tabs = await callApi('tabs.query', [{}], []); for (let tab of tabs) { let host = getHost(tab.url); if (host === domain || host.endsWith('.' + domain)) { @@ -154,7 +154,7 @@ function onTabUpdated(tabId, changeInfo, tab) { async function onTabActivated(activeInfo) { try { - updateIcon(await api.tabs.get(activeInfo.tabId)); + updateIcon(await requireApi('tabs.get', [activeInfo.tabId])); } catch (e) { logError('onTabActivated: tabs.get failed', e); } @@ -178,20 +178,21 @@ async function onInstalled(details) { log('installed:', details.reason); // On first install the options page asks to choose the IDE if (details.reason === 'install' || !await hasHostPermission()) { - api.runtime.openOptionsPage(); + callApi('runtime.openOptionsPage', []); } await updateActiveTabs(); } -// Listeners must be registered synchronously at top level (service worker requirement) -api.tabs.onUpdated.addListener(onTabUpdated); -api.tabs.onActivated.addListener(onTabActivated); -api.windows.onFocusChanged.addListener(updateActiveTabs); -api.cookies.onChanged.addListener(onCookieChanged); -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); +// Listeners must be registered synchronously at top level (service worker requirement). +// Missing events are skipped: e.g. Firefox for Android has no windows and commands (keyboard shortcuts) APIs. +addApiListener('tabs.onUpdated', onTabUpdated); +addApiListener('tabs.onActivated', onTabActivated); +addApiListener('cookies.onChanged', onCookieChanged); +addApiListener('storage.onChanged', onSettingsChanged); +addApiListener('runtime.onInstalled', onInstalled); +addApiListener('windows.onFocusChanged', updateActiveTabs); +addApiListener('commands.onCommand', onCommand); +addApiListener('permissions.onAdded', updateActiveTabs); +addApiListener('permissions.onRemoved', updateActiveTabs); updateActiveTabs(); diff --git a/common.js b/common.js index 08428b0..e1c43d2 100644 --- a/common.js +++ b/common.js @@ -2,8 +2,62 @@ // Firefox exposes `browser`, Chrome exposes `chrome` (both return promises in MV3). const api = globalThis.browser || globalThis.chrome; const LOG_PREFIX = '[XDEBUG_PROFILE]'; + +// --- Safe access to WebExtension APIs --- +// Browsers support different sets of APIs (e.g. Firefox for Android has no windows and commands), +// so every API method and event is checked before use. + +// Returns the API object or function by path ('tabs.getCurrent'), undefined if the browser has none +function getApi(path) { + let node = api; + for (let part of path.split('.')) { + if (node === undefined || node === null) { + return undefined; + } + node = node[part]; + } + return node; +} + +function hasApi(path) { + return getApi(path) !== undefined; +} + +// Calls an API method if the browser has it, otherwise returns fallback. +// The result is returned as is (usually a promise), so callers can await it in both cases. +function callApi(path, args, fallback) { + let method = getApi(path); + if (typeof method !== 'function') { + log('API is not available:', path); + return fallback; + } + return method.apply(getApi(path.split('.').slice(0, -1).join('.')), args || []); +} + +// The same for APIs the action can't work without: throws an error that is shown to the user +function requireApi(path, args) { + let method = getApi(path); + if (typeof method !== 'function') { + throw new Error('API is not available in this browser: ' + path); + } + return method.apply(getApi(path.split('.').slice(0, -1).join('.')), args || []); +} + +// Subscribes to an API event if the browser has it, returns false otherwise +function addApiListener(path, listener) { + let event = getApi(path); + if (!event || typeof event.addListener !== 'function') { + log('API event is not available:', path); + return false; + } + event.addListener(listener); + return true; +} + // firstPartyDomain exists only in Firefox, Chrome rejects unknown properties -const IS_FIREFOX = typeof api.runtime.getBrowserInfo === 'function'; +const IS_FIREFOX = hasApi('runtime.getBrowserInfo'); +// Settings are kept in storage.sync, or in storage.local if the browser has no sync storage +const SETTINGS_AREA = hasApi('storage.sync') ? 'sync' : 'local'; // Supported Xdebug triggers. Storage keys of PROFILE are the same as in old versions. const TRIGGERS = [ @@ -30,7 +84,7 @@ const IDE_CHOSEN_KEY = 'ide_chosen'; // Localized string from _locales/*/messages.json, falls back to the key itself function t(key, ...substitutions) { - let message = api.i18n.getMessage(key, substitutions.map(String)); + let message = callApi('i18n.getMessage', [key, substitutions.map(String)], ''); if (!message) { console.warn(LOG_PREFIX, 'i18n: no message for', key); return key; @@ -84,7 +138,8 @@ function isSupportedUrl(url) { async function hasHostPermission() { try { - return await api.permissions.contains(SITE_ORIGINS); + // Without the permissions API host permissions are granted at install time + return await callApi('permissions.contains', [SITE_ORIGINS], true); } catch (e) { logError('permissions.contains failed', e); return false; @@ -93,7 +148,7 @@ async function hasHostPermission() { // Must be called from a user action handler (click) async function requestHostPermission() { - let granted = await api.permissions.request(SITE_ORIGINS); + let granted = await requireApi('permissions.request', [SITE_ORIGINS]); log('host permission granted:', granted); return granted; } @@ -105,7 +160,7 @@ async function getTriggerSettings(host) { for (let trigger of TRIGGERS) { keys.push(trigger.key, trigger.key + '_value'); } - let config = await api.storage.sync.get(keys); + let config = await callApi('storage.' + SETTINGS_AREA + '.get', [keys], {}); let siteIdeKey = host ? await getSiteIdeKey(host) : ''; return TRIGGERS.map(trigger => Object.assign({}, trigger, { @@ -120,22 +175,22 @@ async function getTriggerSettings(host) { // Useful when some projects work through DBGp proxy with their own key. async function getSiteIdeKey(host) { let key = 'idekey:' + host; - let data = await api.storage.local.get(key); + let data = await callApi('storage.local.get', [key], {}); return data[key] || ''; } async function setSiteIdeKey(host, ideKey) { let key = 'idekey:' + host; if (ideKey) { - await api.storage.local.set({ [key]: ideKey }); + await callApi('storage.local.set', [{ [key]: ideKey }]); } else { - await api.storage.local.remove(key); + await callApi('storage.local.remove', [key]); } log('IDE key for', host, '=', ideKey || '(global)'); } async function getColors() { - let config = await api.storage.sync.get(['cookie_XDEBUG_PROFILE_on_color', 'cookie_XDEBUG_PROFILE_off_color']); + let config = await callApi('storage.' + SETTINGS_AREA + '.get', [['cookie_XDEBUG_PROFILE_on_color', 'cookie_XDEBUG_PROFILE_off_color']], {}); return { on: config.cookie_XDEBUG_PROFILE_on_color || DEFAULT_ON_COLOR, @@ -149,7 +204,7 @@ async function getStoreId(tab) { if (tab.cookieStoreId) { return tab.cookieStoreId; } - let stores = await api.cookies.getAllCookieStores(); + let stores = await callApi('cookies.getAllCookieStores', [], []); for (let store of stores) { if (store.tabIds && store.tabIds.includes(tab.id)) { return store.id; @@ -184,7 +239,7 @@ function getDomainCandidates(host) { // Scope is remembered per host: '' - host-only cookie, 'example.com' - cookie for .example.com and subdomains async function getScope(host) { let key = 'scope:' + host; - let data = await api.storage.local.get(key); + let data = await callApi('storage.local.get', [key], {}); let scope = data[key] || ''; if (scope && !getDomainCandidates(host).includes(scope)) { return ''; @@ -195,9 +250,9 @@ async function getScope(host) { async function setScope(host, scope) { let key = 'scope:' + host; if (scope) { - await api.storage.local.set({ [key]: scope }); + await callApi('storage.local.set', [{ [key]: scope }]); } else { - await api.storage.local.remove(key); + await callApi('storage.local.remove', [key]); } log('scope for', host, '=', scope || '(host only)'); } @@ -209,11 +264,11 @@ async function getCookie(tab, trigger) { let cookie = null; try { - cookie = await api.cookies.get({ + cookie = await requireApi('cookies.get', [{ url: tab.url, name: trigger.name, storeId: await getStoreId(tab) - }); + }]); } catch (e) { logError('getCookie failed for', tab.url, trigger.name, e); return null; @@ -252,7 +307,7 @@ async function removeCookie(cookie) { if (IS_FIREFOX && cookie.firstPartyDomain !== undefined) { details.firstPartyDomain = cookie.firstPartyDomain; } - await api.cookies.remove(details); + await requireApi('cookies.remove', [details]); log('removed', cookie.name, 'for', details.url, 'store', cookie.storeId); } @@ -266,7 +321,7 @@ async function disableTrigger(tab, trigger) { if (IS_FIREFOX) { query.firstPartyDomain = null; } - let cookies = await api.cookies.getAll(query); + let cookies = await requireApi('cookies.getAll', [query]); for (let cookie of cookies) { await removeCookie(cookie); } @@ -285,7 +340,7 @@ async function enableTrigger(tab, trigger) { if (scope) { details.domain = scope; } - await api.cookies.set(details); + await requireApi('cookies.set', [details]); log('set', trigger.name + '=' + trigger.value, 'for', scope ? '.' + scope : host, 'store', details.storeId); } @@ -319,6 +374,15 @@ async function reapplyTriggers(tab, triggerIds) { } async function getActiveTab() { - let tabs = await api.tabs.query({ active: true, currentWindow: true }); + let tabs = await callApi('tabs.query', [{ active: true, currentWindow: true }], []); + // Firefox for Android shows the popup as a page in its own tab: then the active tab is the + // popup itself, and the site is the most recently used other tab + let ownTab = typeof document !== 'undefined' ? await callApi('tabs.getCurrent', [], undefined) : undefined; + if (ownTab && tabs[0] && tabs[0].id === ownTab.id) { + let others = (await callApi('tabs.query', [{}], [])).filter(tab => tab.id !== ownTab.id); + others.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0)); + log('getActiveTab: popup is shown in a tab, using the last used tab', others[0] && others[0].url); + return others[0]; + } return tabs[0]; } diff --git a/options.js b/options.js index 9cda587..01025c4 100644 --- a/options.js +++ b/options.js @@ -48,8 +48,12 @@ async function saveOptions(e) { } console.debug(OPTIONS_LOG_PREFIX, 'saving', options); - let saved = api.storage.sync.set(options); - saved.then(showSuccess, showError); + try { + await requireApi('storage.' + SETTINGS_AREA + '.set', [options]); + showSuccess(); + } catch (err) { + showError(err); + } } async function restoreOptions() { @@ -80,7 +84,7 @@ async function restoreOptions() { container.appendChild(rows); } - let config = await api.storage.sync.get(IDE_CHOSEN_KEY); + let config = await callApi('storage.' + SETTINGS_AREA + '.get', [IDE_CHOSEN_KEY], {}); document.querySelector(".welcome-row").hidden = !!config[IDE_CHOSEN_KEY]; document.querySelector(".cookie-XDEBUG_PROFILE-on-color[value="+ colors.on +"]").checked = true; diff --git a/package.json b/package.json index a53ce4f..dfe4c73 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "scripts": { "lint": "web-ext lint", "build": "web-ext build --overwrite-dest", - "test": "npm run test:chrome", + "test": "npm run test:unit && npm run test:chrome", "test:chrome": "node --test --test-concurrency=1 tests/chrome.test.js", - "test:firefox": "npm run build && node --test --test-concurrency=1 tests/firefox.test.js" + "test:firefox": "npm run build && node --test --test-concurrency=1 tests/firefox.test.js", + "test:unit": "node --test tests/android-env.test.js" }, "devDependencies": { "playwright": "1.56.1", diff --git a/popup.js b/popup.js index ea3b427..1290b09 100644 --- a/popup.js +++ b/popup.js @@ -10,7 +10,7 @@ function showError(message) { async function render() { // popup.html?tabId=N opens the popup for a given tab (for debugging in a regular tab) let debugTabId = new URLSearchParams(location.search).get('tabId'); - popupTab = debugTabId ? await api.tabs.get(Number(debugTabId)) : await getActiveTab(); + popupTab = debugTabId ? await callApi('tabs.get', [Number(debugTabId)], undefined) : await getActiveTab(); let host = popupTab ? getHost(popupTab.url) : ''; log('popup: render for', popupTab && popupTab.url); @@ -173,7 +173,7 @@ document.querySelector('.site-ide-custom-row').addEventListener('submit', onSite document.querySelector('.grant').addEventListener('click', onGrantClick); function openOptions(e) { e.preventDefault(); - api.runtime.openOptionsPage(); + callApi('runtime.openOptionsPage', []); window.close(); } diff --git a/store/listing-en.md b/store/listing-en.md index 3a2bb1d..1ab801f 100644 --- a/store/listing-en.md +++ b/store/listing-en.md @@ -22,6 +22,7 @@ Features: • Custom cookie names and values (xdebug.trigger_value) • 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) +• Firefox for Android is supported (without keyboard shortcuts) • English, Russian and Kazakh interface Server setup (Xdebug 3): diff --git a/store/listing-ru.md b/store/listing-ru.md index e1ac4e2..87b9b9f 100644 --- a/store/listing-ru.md +++ b/store/listing-ru.md @@ -22,6 +22,7 @@ Xdebug Cookies: Debug, Profile, Trace • Свои имена и значения cookie (xdebug.trigger_value) • Горячие клавиши: Alt+Shift+F — профайлер, Alt+Shift+D — отладчик, Alt+Shift+K — окно • Работает с контейнерами Firefox, приватными окнами и режимом инкогнито Chrome (после того как вы разрешите там расширение в настройках дополнений браузера) +• Поддерживается Firefox для Android (без горячих клавиш) • Интерфейс на английском, русском и казахском Настройка сервера (Xdebug 3): diff --git a/tests/android-env.test.js b/tests/android-env.test.js new file mode 100644 index 0000000..461c6cf --- /dev/null +++ b/tests/android-env.test.js @@ -0,0 +1,106 @@ +// Background script and API helpers in reduced environments, without a browser. +// Firefox for Android has no windows and commands APIs (@mdn/browser-compat-data: +// webextensions.api.windows / commands - firefox_android: false), other browsers may lack other APIs. +// Run: node --test tests/android-env.test.js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function event(name, registered) { + return { addListener: () => registered.push(name) }; +} + +// Loads common.js (+ background.js) into a sandbox with the given `browser` object +function load(browser, files) { + const errors = []; + const context = vm.createContext({ + browser: browser, + console: { debug: () => {}, warn: () => {}, error: (...args) => errors.push(args.map(String).join(' ')) }, + URL: URL + }); + context.globalThis = context; + for (const file of files) { + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', file), 'utf8'), context, { filename: file }); + } + return { context, errors }; +} + +// Firefox for Android: everything except windows and commands +function androidBrowser(registered, calls) { + return { + runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }), onInstalled: event('runtime.onInstalled', registered), openOptionsPage: async () => {} }, + i18n: { getMessage: key => key }, + tabs: { query: async () => [], get: async () => ({}), onUpdated: event('tabs.onUpdated', registered), onActivated: event('tabs.onActivated', registered) }, + cookies: { onChanged: event('cookies.onChanged', registered), getAllCookieStores: async () => [], get: async () => null }, + storage: { onChanged: event('storage.onChanged', registered), sync: { get: async () => ({}) }, local: { get: async () => ({}) } }, + permissions: { contains: async () => true, onAdded: event('permissions.onAdded', registered), onRemoved: event('permissions.onRemoved', registered) }, + action: { + setIcon: async () => calls.push('setIcon'), + setTitle: async () => calls.push('setTitle'), + setBadgeText: async () => calls.push('setBadgeText'), + setBadgeBackgroundColor: async () => calls.push('setBadgeBackgroundColor') + } + }; +} + +test('background script starts without windows and commands APIs (Firefox for Android)', async () => { + const registered = []; + const { errors } = load(androidBrowser(registered, []), ['common.js', 'background.js']); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.deepEqual(registered.sort(), [ + 'cookies.onChanged', 'permissions.onAdded', 'permissions.onRemoved', 'runtime.onInstalled', + 'storage.onChanged', 'tabs.onActivated', 'tabs.onUpdated' + ]); + assert.deepEqual(errors, []); +}); + +test('icon is updated with a partial action API and without storage.sync and permissions', async () => { + const registered = []; + const calls = []; + const browser = androidBrowser(registered, calls); + delete browser.action.setBadgeBackgroundColor; + delete browser.permissions; + delete browser.storage.sync; + let localGets = 0; + browser.storage.local.get = async () => { + localGets++; + return {}; + }; + const { context, errors } = load(browser, ['common.js', 'background.js']); + // Top-level const is not a property of the context, so it is read by an expression + assert.equal(vm.runInContext('SETTINGS_AREA', context), 'local', 'settings fall back to storage.local'); + await context.updateIcon({ id: 1, url: 'http://app.example.com/' }); + assert.deepEqual(calls, ['setIcon', 'setTitle', 'setBadgeText']); + assert.ok(localGets > 0, 'settings are read from storage.local'); + assert.deepEqual(errors, []); +}); + +test('API helpers: getApi, hasApi, callApi, requireApi, addApiListener', () => { + const registered = []; + const browser = { + runtime: {}, + tabs: { query: function (info) { return { self: this === browser.tabs, info: info }; }, onUpdated: event('tabs.onUpdated', registered) } + }; + const { context } = load(browser, ['common.js']); + assert.equal(context.hasApi('tabs.query'), true); + assert.equal(context.hasApi('tabs.getCurrent'), false); + assert.equal(context.hasApi('windows.onFocusChanged'), false); + assert.equal(context.getApi('nothing.at.all'), undefined); + // Method is called with its namespace as `this` + assert.deepEqual(JSON.parse(JSON.stringify(context.callApi('tabs.query', [{ active: true }], 'fallback'))), { self: true, info: { active: true } }); + assert.equal(context.callApi('tabs.getCurrent', [], 'fallback'), 'fallback'); + assert.throws(() => context.requireApi('cookies.set', [{}]), /API is not available in this browser: cookies\.set/); + assert.equal(context.addApiListener('tabs.onUpdated', () => {}), true); + assert.equal(context.addApiListener('commands.onCommand', () => {}), false); + assert.deepEqual(registered, ['tabs.onUpdated']); + assert.equal(vm.runInContext('IS_FIREFOX', context), false); +}); + +test('toggle shows a clear error when the cookies API is missing', async () => { + const browser = androidBrowser([], []); + delete browser.cookies.set; + const { context } = load(browser, ['common.js']); + await assert.rejects(context.toggleTrigger({ id: 1, url: 'http://app.example.com/' }, 'profile'), /API is not available in this browser: cookies\.set/); +}); diff --git a/tests/chrome.test.js b/tests/chrome.test.js index 0f3187a..3a337c2 100644 --- a/tests/chrome.test.js +++ b/tests/chrome.test.js @@ -8,7 +8,7 @@ const { SITE, OTHER_SITE, startServer, eventually } = require('./helpers'); const EXT_DIR = path.resolve(__dirname, '..'); -async function launch(lang) { +async function launch(lang, extraArgs) { const args = [ `--disable-extensions-except=${EXT_DIR}`, `--load-extension=${EXT_DIR}`, @@ -19,6 +19,7 @@ async function launch(lang) { if (lang) { args.push('--lang=' + lang); } + args.push(...(extraArgs || [])); const context = await chromium.launchPersistentContext('', { executablePath: process.env.CHROMIUM_PATH || undefined, headless: false, @@ -92,6 +93,16 @@ describe('Chromium', () => { ]); }); + it('popup opened as a tab (as on Firefox for Android) works for the last used site tab', async () => { + await site.bringToFront(); + const asTab = await context.newPage(); + await asTab.goto(`chrome-extension://${extensionId}/popup.html`); + await eventually(async () => assert.equal(await asTab.textContent('.host'), 'app.example.co.uk')); + await asTab.waitForSelector('.trigger input'); + assert.ok(await asTab.isHidden('.unsupported')); + await asTab.close(); + }); + it('toggles profile cookie and badge', async () => { await popup.check('.trigger input[data-trigger=profile]'); // Popup sets the cookie asynchronously after the click @@ -290,7 +301,9 @@ describe('Chromium incognito', () => { before(async () => { server = await startServer(); - ({ context, extensionId } = await launch()); + // Incognito tries https:// first (HTTPS-First mode) and the http-only test server makes + // the page load wait for that attempt; Playwright's own HttpsUpgrades switch is kept + ({ context, extensionId } = await launch(undefined, ['--disable-features=HttpsUpgrades,HttpsFirstModeIncognito'])); }); after(async () => { @@ -317,7 +330,8 @@ describe('Chromium incognito', () => { let tab; for (let i = 0; i < 50; i++) { tab = (await api.tabs.query({ windowId: win.id }))[0]; - if (tab.status === 'complete' && tab.url) { + // A new window first reports "complete" for about:blank, so wait for the site itself + if (tab.status === 'complete' && tab.url === site) { break; } await new Promise(r => setTimeout(r, 200)); diff --git a/tests/firefox.test.js b/tests/firefox.test.js index dcaabf3..749c7d9 100644 --- a/tests/firefox.test.js +++ b/tests/firefox.test.js @@ -175,6 +175,16 @@ describe('Firefox', () => { assert.deepEqual(labels, ['Debug', 'Profile', 'Trace']); }); + it('popup opened as a tab (as on Firefox for Android) works for the last used site tab', async () => { + await driver.switchTo().window(siteHandle); + await driver.switchTo().window(popupHandle); + await openExtensionPage(BASE + 'popup.html'); + await eventually(async () => assert.equal(await driver.findElement(By.css('.host')).getText(), 'app.example.co.uk')); + assert.ok(!await isVisible('.unsupported')); + assert.ok((await driver.findElements(By.css('.trigger input'))).length === 3); + await openPopup(); + }); + it('toggles profile cookie and badge', async () => { await driver.findElement(By.css('.trigger input[data-trigger=profile]')).click(); await eventually(async () => assert.match(await sitePageText(), /XDEBUG_PROFILE=Y/)); @@ -311,7 +321,12 @@ describe('Firefox', () => { }, 10000); assert.equal(await inExtension('browser.extension.isAllowedIncognitoAccess'), true); await openExtensionPage(BASE + 'options.html'); - const before = await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-private' }); + // Right after the addon reload the page may still be initializing, so the call is retried + const before = await eventually(async () => { + const tabs = await inExtension('browser.tabs.query', { cookieStoreId: 'firefox-private' }); + assert.ok(Array.isArray(tabs)); + return tabs; + }, 10000); 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');