diff --git a/Extensions/combined/popup-colors.spec.js b/Extensions/combined/popup-colors.spec.js index e512c94..271564d 100644 --- a/Extensions/combined/popup-colors.spec.js +++ b/Extensions/combined/popup-colors.spec.js @@ -92,6 +92,12 @@ describe("popup color settings", () => { expect(chrome.storage.sync.set).not.toHaveBeenCalled(); }); + test("uses the default number format before first-run storage is initialized", () => { + expect(() => openPopup({ numberDisplayFormat: undefined })).not.toThrow(); + expect(document.getElementById("number_format").value).toBe("compactShort"); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + }); + test("uses a keyboard-compatible change event for preset selection and previews both themes", () => { openPopup(); chooseTheme("hibiscus"); diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js index 77cfd26..8c6fe6e 100644 --- a/Extensions/combined/popup.js +++ b/Extensions/combined/popup.js @@ -60,7 +60,28 @@ function localizeHtmlPage() { localizeHtmlPage(); const privateBrowsing = isPrivateBrowsingContext(); -const coreConsentReady = privateBrowsing ? null : waitForCoreDataConsent({ alwaysExplain: true }); +function isToolbarPopup() { + try { + return browser.extension?.getViews?.({ type: "popup" }).includes(window) === true; + } catch (_) { + return false; + } +} + +function waitForPopupCoreConsent() { + const toolbarPopup = isToolbarPopup(); + return waitForCoreDataConsent({ + alwaysExplain: !toolbarPopup, + openConsentPage: toolbarPopup + ? async () => { + await browser.tabs.create({ url: browser.runtime.getURL("consent.html"), active: true }); + window.close(); + } + : undefined, + }); +} + +const coreConsentReady = privateBrowsing ? null : waitForPopupCoreConsent(); if (privateBrowsing) { const home = document.createElement("center"); const message = document.createElement("p"); @@ -76,7 +97,7 @@ if (privateBrowsing) { } else { coreConsentReady.then(() => initDiscussionPopup()); onCoreDataPermissionChanged((granted) => { - if (!granted) void waitForCoreDataConsent({ alwaysExplain: true }); + if (!granted) void waitForPopupCoreConsent(); }); /* Links */ @@ -174,6 +195,7 @@ function initPatreonAuth() { const userName = document.getElementById("patreon-user-name"); const userTier = document.getElementById("patreon-tier"); let authenticationGeneration = 0; + let openingPermissionPage = false; const initialGeneration = authenticationGeneration; chrome.storage.sync.get(["patreonUser", "patreonSessionToken"], async (data) => { @@ -234,6 +256,8 @@ function initPatreonAuth() { userAvatar.style.display = "none"; userName.textContent = ""; userTier.textContent = ""; + if (window.location.hash === "#patreon-login-btn") loginBtn.focus(); + if (window.location.hash === "#github-login-btn") githubLoginBtn?.focus(); } function showLoggedInView(user) { @@ -402,7 +426,43 @@ function initPatreonAuth() { } } + function requestLoginConsent(buttonId, generation, continueLogin) { + if (!usesFirefoxDataCollectionConsent()) { + continueLogin(true); + return; + } + if (!isToolbarPopup()) { + requestAuthenticationDataPermission().then(continueLogin); + return; + } + + // Keep the native permission prompt and its continuation outside the toolbar popup. + openingPermissionPage = true; + void hasAuthenticationDataPermission() + .then(async (granted) => { + if (generation !== authenticationGeneration) return; + if (granted) { + continueLogin(true); + return; + } + await browser.tabs.create({ url: browser.runtime.getURL("popup.html") + "#" + buttonId, active: true }); + if (generation === authenticationGeneration) window.close(); + }) + .catch(() => { + if (generation !== authenticationGeneration) return; + alert( + chrome.i18n.getMessage( + buttonId === "github-login-btn" ? "githubLoginStartFailed" : "patreonLoginStartFailed", + ), + ); + }) + .finally(() => { + openingPermissionPage = false; + }); + } + loginBtn.addEventListener("click", () => { + if (openingPermissionPage) return; const generation = ++authenticationGeneration; const continueLogin = (consentGranted) => { if (generation !== authenticationGeneration) return; @@ -439,16 +499,12 @@ function initPatreonAuth() { }); }; - if (!usesFirefoxDataCollectionConsent()) { - continueLogin(true); - return; - } - - requestAuthenticationDataPermission().then(continueLogin); + requestLoginConsent("patreon-login-btn", generation, continueLogin); }); if (githubLoginBtn) { githubLoginBtn.addEventListener("click", () => { + if (openingPermissionPage) return; const generation = ++authenticationGeneration; const continueLogin = (consentGranted) => { if (generation !== authenticationGeneration) return; @@ -485,12 +541,7 @@ function initPatreonAuth() { }); }; - if (!usesFirefoxDataCollectionConsent()) { - continueLogin(true); - return; - } - - requestAuthenticationDataPermission().then(continueLogin); + requestLoginConsent("github-login-btn", generation, continueLogin); }); } @@ -731,7 +782,7 @@ function updateColorThemePreviewContent() { } } -function handleNumberDisplayFormatChangeEvent(value) { +function handleNumberDisplayFormatChangeEvent(value = "compactShort") { config.numberDisplayFormat = value; document.getElementById("number_format").querySelector('option[value="' + value + '"]').selected = true; } diff --git a/Extensions/combined/popup.spec.js b/Extensions/combined/popup.spec.js index 6aac2f0..c9c2b15 100644 --- a/Extensions/combined/popup.spec.js +++ b/Extensions/combined/popup.spec.js @@ -1,7 +1,7 @@ /** @jest-environment jsdom */ const fs = require("fs"); const path = require("path"); -jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: () => Promise.resolve() })); +jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: jest.fn(() => Promise.resolve()) })); jest.mock("./src/core-data-consent", () => ({ fetchWithCoreConsent: (...args) => fetch(...args), onCoreDataPermissionChanged: jest.fn(), @@ -29,7 +29,13 @@ describe("popup account consent lifecycle", () => { document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8"); global.chrome = { i18n: { getMessage: () => "" }, - runtime: { getManifest: () => require("./manifest-firefox.json"), sendMessage: jest.fn() }, + runtime: { + getManifest: () => require("./manifest-firefox.json"), + getURL: (path) => `moz-extension://fixture/${path}`, + sendMessage: jest.fn(), + }, + extension: { getViews: jest.fn(() => []) }, + tabs: { create: jest.fn().mockResolvedValue({ id: 9 }) }, permissions: { getAll: jest.fn(() => Promise.resolve({ data_collection: consent ? ["authenticationInfo"] : [] })), contains: jest.fn().mockResolvedValue(true), @@ -65,12 +71,53 @@ describe("popup account consent lifecycle", () => { }); afterEach(() => { + window.location.hash = ""; jest.restoreAllMocks(); delete global.chrome; delete global.browser; delete global.fetch; }); + test.each(["patreon-login-btn", "github-login-btn"])( + "focuses the selected %s after the asynchronous account view becomes visible", + async (buttonId) => { + window.location.hash = `#${buttonId}`; + const button = document.getElementById(buttonId); + const focus = jest.spyOn(button, "focus").mockImplementation(() => { + expect(document.getElementById("patreon-logged-out").style.display).toBe("block"); + }); + expect(document.getElementById("patreon-logged-out").style.display).toBe("none"); + await readCachedSession({}); + expect(focus).toHaveBeenCalledTimes(1); + }, + ); + + test.each([true, false])("moves toolbar core consent into a tab and closes only on success=%s", async (success) => { + chrome.extension.getViews.mockReturnValue([window]); + jest.resetModules(); + const waitForConsent = require("./src/consent-prompt").waitForCoreDataConsent; + require("./popup"); + const options = waitForConsent.mock.calls[0][0]; + expect(options.alwaysExplain).toBe(false); + expect(options.openConsentPage).toEqual(expect.any(Function)); + const close = jest.spyOn(window, "close").mockImplementation(() => {}); + let complete; + chrome.tabs.create.mockImplementation( + () => + new Promise((resolve, reject) => { + complete = () => (success ? resolve({ id: 9 }) : reject(new Error("Could not open tab"))); + }), + ); + const opening = options.openConsentPage(); + expect(chrome.tabs.create).toHaveBeenCalledWith({ url: "moz-extension://fixture/consent.html", active: true }); + expect(close).not.toHaveBeenCalled(); + complete(); + if (success) await opening; + else await expect(opening).rejects.toThrow("Could not open tab"); + expect(close).toHaveBeenCalledTimes(success ? 1 : 0); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + }); + test.each([ ["github_not_configured", "githubLoginUnavailable"], ["github_unavailable", "githubLoginUnavailable"], @@ -140,6 +187,80 @@ describe("popup account consent lifecycle", () => { }, ); + test.each(["patreon-login-btn", "github-login-btn"])( + "moves missing %s consent out of the toolbar before requesting permission", + async (button) => { + consent = false; + chrome.extension.getViews.mockReturnValue([window]); + const close = jest.spyOn(window, "close").mockImplementation(() => {}); + let opened; + chrome.tabs.create.mockImplementation(() => new Promise((resolve) => (opened = resolve))); + document.getElementById(button).click(); + document.getElementById(button).click(); + await flushPromises(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: `moz-extension://fixture/popup.html#${button}`, + active: true, + }); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + opened({ id: 9 }); + await flushPromises(); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + + test.each(["patreon", "github"])("keeps already-granted %s toolbar login in the existing flow", async (provider) => { + chrome.extension.getViews.mockReturnValue([window]); + document.getElementById(`${provider}-login-btn`).click(); + await flushPromises(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + { message: `${provider}_oauth_login` }, + expect.any(Function), + ); + }); + + test("failed consent-tab opening leaves the toolbar open and can be retried", async () => { + consent = false; + chrome.extension.getViews.mockReturnValue([window]); + chrome.tabs.create.mockRejectedValueOnce(new Error("Tab could not open")); + const close = jest.spyOn(window, "close").mockImplementation(() => {}); + const alert = jest.spyOn(window, "alert").mockImplementation(() => {}); + document.getElementById("github-login-btn").click(); + await flushPromises(); + expect(alert).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + document.getElementById("github-login-btn").click(); + await flushPromises(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(2); + expect(close).toHaveBeenCalledTimes(1); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + + test.each(["revocation", "logout"])( + "does not open a consent tab after %s during its permission query", + async (event) => { + chrome.extension.getViews.mockReturnValue([window]); + let resolvePermission; + chrome.permissions.getAll.mockImplementationOnce(() => new Promise((resolve) => (resolvePermission = resolve))); + document.getElementById("patreon-login-btn").click(); + if (event === "revocation") removeCoreConsent(); + else document.getElementById("patreon-logout-btn").click(); + chrome.runtime.sendMessage.mockClear(); + resolvePermission({ data_collection: [] }); + await flushPromises(); + expect(chrome.tabs.create).not.toHaveBeenCalled(); + expect(chrome.permissions.request).not.toHaveBeenCalled(); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }, + ); + test("does not verify or show a cached session whose storage read completes after revocation", async () => { revoke(); readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" }); diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js index 5ad4341..9345ef0 100644 --- a/Extensions/combined/ryd.background.js +++ b/Extensions/combined/ryd.background.js @@ -262,7 +262,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { function handleMessage(request, sender, sendResponse) { if (request?.message === "ryd_open_consent") { - if (sender.id !== api.runtime.id || sender.url !== api.runtime.getURL("consent.html")) { + let isYouTubeLauncher = false; + if (sender.tab?.incognito === false && sender.frameId === 0) { + try { + const { protocol, hostname } = new URL(sender.url); + isYouTubeLauncher = + (protocol === "https:" || protocol === "http:") && + (hostname === "youtube.com" || hostname.endsWith(".youtube.com")); + } catch (_) {} + } + if (sender.id !== api.runtime.id || (sender.url !== api.runtime.getURL("consent.html") && !isYouTubeLauncher)) { sendResponse({ success: false }); return; } @@ -273,6 +282,7 @@ function handleMessage(request, sender, sendResponse) { try { const existing = await tabs.get(consentPageTabId); if (existing.url === api.runtime.getURL("consent.html") && existing.incognito === false) { + await tabs.reload(consentPageTabId); await tabs.update(consentPageTabId, { active: true }); return; } diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js index 53042ce..e4739ac 100644 --- a/Extensions/combined/ryd.background.spec.js +++ b/Extensions/combined/ryd.background.spec.js @@ -93,6 +93,7 @@ describe("background account consent lifecycle", () => { tabs: { get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html", incognito: false })), create: jest.fn(async () => ({ id: 9 })), + reload: jest.fn(async () => {}), update: jest.fn(async () => ({ id: 9 })), query: jest.fn((query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456", incognito: false }]), @@ -331,18 +332,55 @@ describe("background account consent lifecycle", () => { ).not.toHaveBeenCalled(); }); - test("only the explainer can open the consent page and repeated clicks reuse the tab", async () => { + test.each([ + { url: "moz-extension://fixture/consent.html" }, + { url: "https://www.youtube.com/watch?v=video123456", frameId: 0 }, + { url: "https://m.youtube.com/", frameId: 0 }, + { url: "http://youtube.com/", frameId: 0 }, + ])( + "the explainer or top-level YouTube launcher opens and refreshes the reused consent page (%j)", + async (context) => { + const response = jest.fn(); + const sender = { ...normalSender, ...context }; + messageListener({ message: "ryd_open_consent" }, sender, response); + messageListener({ message: "ryd_open_consent" }, sender, response); + await flushPromises(); + expect(response).toHaveBeenCalledWith({ success: true }); + expect(chrome.tabs.reload).not.toHaveBeenCalled(); + messageListener({ message: "ryd_open_consent" }, sender, response); + await flushPromises(); + expect(chrome.tabs.create).toHaveBeenCalledTimes(1); + expect(chrome.tabs.reload).toHaveBeenCalledTimes(1); + expect(chrome.tabs.reload).toHaveBeenCalledWith(9); + expect(chrome.tabs.update).toHaveBeenCalledWith(9, { active: true }); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { id: "other-extension" }, + { frameId: 1 }, + { frameId: undefined }, + { tab: undefined }, + { tab: { id: 2, incognito: true } }, + { tab: { id: 2 } }, + { url: "https://example.com/" }, + { url: "https://youtube.com.example.com/" }, + { url: "https://youtube.com@example.com/" }, + { url: "blob:https://www.youtube.com/test" }, + { url: "not a URL" }, + ])("rejects unrelated consent-page opener contexts (%j)", async (override) => { const response = jest.fn(); - const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" }; - messageListener({ message: "ryd_open_consent" }, { ...sender, url: "https://www.youtube.com/" }, response); - expect(response).toHaveBeenCalledWith({ success: false }); + messageListener( + { message: "ryd_open_consent" }, + { ...normalSender, url: "https://www.youtube.com/watch?v=video123456", frameId: 0, ...override }, + response, + ); + await flushPromises(); + expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false })); expect(chrome.tabs.create).not.toHaveBeenCalled(); - messageListener({ message: "ryd_open_consent" }, sender, response); - await flushPromises(); - messageListener({ message: "ryd_open_consent" }, sender, response); - await flushPromises(); - expect(chrome.tabs.create).toHaveBeenCalledTimes(1); - expect(chrome.tabs.update).toHaveBeenCalledWith(9, { active: true }); + expect(chrome.tabs.update).not.toHaveBeenCalled(); + expect(chrome.tabs.reload).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); @@ -355,6 +393,7 @@ describe("background account consent lifecycle", () => { await flushPromises(); expect(chrome.tabs.create).toHaveBeenCalledTimes(2); expect(chrome.tabs.update).not.toHaveBeenCalled(); + expect(chrome.tabs.reload).not.toHaveBeenCalled(); }); test.each(["patreon_oauth_login", "github_oauth_login"])( diff --git a/Extensions/combined/src/consent-prompt.js b/Extensions/combined/src/consent-prompt.js index 7275a55..485d79b 100644 --- a/Extensions/combined/src/consent-prompt.js +++ b/Extensions/combined/src/consent-prompt.js @@ -4,14 +4,16 @@ import { isPrivateBrowsingContext } from "./private-browsing"; let pendingConsent = null; -function waitForCoreDataConsent({ alwaysExplain = false } = {}) { +function waitForCoreDataConsent({ alwaysExplain = false, openConsentPage } = {}) { if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(); if (pendingConsent) return pendingConsent; pendingConsent = (async () => { if (await hasCoreDataPermission()) return; const api = consentApi(); + const privateBrowsing = isPrivateBrowsingContext(); + const useLauncher = !alwaysExplain && !privateBrowsing; let dismissed = false; - if (!isPrivateBrowsingContext()) { + if (!privateBrowsing) { try { dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true; } catch (_) {} @@ -31,10 +33,12 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) { font: 600 14px system-ui; background: light-dark(#fff, #202020); color: light-dark(#151515, #fff); } [hidden] { display: none; } `; - const frame = document.createElement("iframe"); + const frame = useLauncher ? null : document.createElement("iframe"); const url = api.runtime.getURL("consent.html"); - frame.src = url; - frame.title = "Enable Return YouTube Dislike"; + if (frame) { + frame.src = url; + frame.title = "Enable Return YouTube Dislike"; + } const reopen = document.createElement("button"); reopen.type = "button"; reopen.textContent = "Enable Return YouTube Dislike"; @@ -46,13 +50,29 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) { } function collapse() { host.setAttribute("collapsed", ""); - frame.hidden = true; + if (frame) frame.hidden = true; reopen.hidden = false; } - reopen.addEventListener("click", expand); - if (dismissed && !alwaysExplain) collapse(); + reopen.addEventListener( + "click", + useLauncher + ? async () => { + reopen.disabled = true; + try { + if (openConsentPage) await openConsentPage(); + else await api.runtime.sendMessage({ message: "ryd_open_consent" }); + } catch (_) { + } finally { + reopen.disabled = false; + } + } + : expand, + ); + if (useLauncher || (dismissed && !alwaysExplain)) collapse(); else expand(); - shadow.append(style, frame, reopen); + shadow.append(style); + if (frame) shadow.append(frame); + shadow.append(reopen); (document.body || document.documentElement).append(host); let requestPending = false; const unsubscribe = onCoreDataPermissionChanged((granted) => { @@ -65,7 +85,7 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) { resolve(); } function onMessage(event) { - if (event.source !== frame.contentWindow || event.origin !== new URL(url).origin) return; + if (!frame || event.source !== frame.contentWindow || event.origin !== new URL(url).origin) return; if (event.data?.type === "ryd-consent-requested") requestPending = true; if (event.data?.type === "ryd-consent-dismissed") { requestPending = false; diff --git a/Extensions/combined/src/consent-prompt.spec.js b/Extensions/combined/src/consent-prompt.spec.js index 566f28e..a6bb0b8 100644 --- a/Extensions/combined/src/consent-prompt.spec.js +++ b/Extensions/combined/src/consent-prompt.spec.js @@ -12,6 +12,7 @@ describe("consent overlay lifecycle", () => { let core; let changed; let local; + let sendMessage; let waitForConsent; beforeEach(() => { jest.resetModules(); @@ -19,9 +20,10 @@ describe("consent overlay lifecycle", () => { require("./data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true); require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(false); local = { get: jest.fn(async () => ({})), set: jest.fn(), remove: jest.fn() }; + sendMessage = jest.fn().mockResolvedValue({ success: true }); core.consentApi.mockReturnValue({ storage: { local }, - runtime: { getURL: () => "moz-extension://fixture/consent.html" }, + runtime: { getURL: () => "moz-extension://fixture/consent.html", sendMessage }, }); core.hasCoreDataPermission.mockResolvedValue(false); core.onCoreDataPermissionChanged.mockImplementation((listener) => { @@ -38,7 +40,7 @@ describe("consent overlay lifecycle", () => { await flush(); const host = document.querySelector("#ryd-consent-prompt"); const frame = host.shadowRoot.querySelector("iframe"); - Object.defineProperty(frame, "contentWindow", { value: {} }); + if (frame) Object.defineProperty(frame, "contentWindow", { value: {} }); return { host, frame }; } function message(frame, type, source = frame.contentWindow) { @@ -54,24 +56,88 @@ describe("consent overlay lifecycle", () => { test("deduplicates prompts and does not resolve until consent is granted", async () => { const pending = waitForConsent(); expect(waitForConsent()).toBe(pending); - const { host } = await mounted(); - expect(host.hasAttribute("collapsed")).toBe(false); + const { host, frame } = await mounted(); + expect(host.hasAttribute("collapsed")).toBe(true); + expect(frame).toBeNull(); + expect(sendMessage).not.toHaveBeenCalled(); changed(true); await pending; expect(host.isConnected).toBe(false); }); - test("a dismissed prompt stays collapsed and can be reopened", async () => { - local.get.mockResolvedValue({ rydConsentDismissed: true }); + test.each([false, true])( + "the launcher opens the explainer once without granting consent (dismissed=%s)", + async (dismissed) => { + local.get.mockResolvedValue({ rydConsentDismissed: dismissed }); + let opened; + sendMessage.mockImplementation(() => new Promise((resolve) => (opened = resolve))); + const pending = waitForConsent(); + const resolved = jest.fn(); + void pending.then(resolved); + const { host } = await mounted(); + expect(host.hasAttribute("collapsed")).toBe(true); + const button = host.shadowRoot.querySelector("button"); + button.click(); + button.click(); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith({ message: "ryd_open_consent" }); + expect(button.disabled).toBe(true); + opened({ success: true }); + await flush(); + expect(button.disabled).toBe(false); + expect(host.hasAttribute("collapsed")).toBe(true); + expect(host.shadowRoot.querySelector("iframe")).toBeNull(); + expect(resolved).not.toHaveBeenCalled(); + changed(false); + expect(host.isConnected).toBe(true); + changed(true); + await pending; + expect(host.isConnected).toBe(false); + }, + ); + + test.each(["rejected", "failed"])("opening the explainer can be retried after a %s request", async (failure) => { + if (failure === "rejected") sendMessage.mockRejectedValueOnce(new Error("Unavailable")); + else sendMessage.mockResolvedValueOnce({ success: false }); const pending = waitForConsent(); const { host } = await mounted(); - expect(host.hasAttribute("collapsed")).toBe(true); - host.shadowRoot.querySelector("button").click(); - expect(host.hasAttribute("collapsed")).toBe(false); + const button = host.shadowRoot.querySelector("button"); + button.click(); + await flush(); + expect(button.disabled).toBe(false); + expect(host.isConnected).toBe(true); + button.click(); + await flush(); + expect(sendMessage).toHaveBeenCalledTimes(2); changed(true); await pending; }); + test("uses the popup's tab opener and permits retry when opening fails", async () => { + const openConsentPage = jest.fn().mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValueOnce(); + const pending = waitForConsent({ openConsentPage }); + const { host, frame } = await mounted(); + expect(frame).toBeNull(); + const button = host.shadowRoot.querySelector("button"); + button.click(); + await flush(); + expect(button.disabled).toBe(false); + button.click(); + await flush(); + expect(openConsentPage).toHaveBeenCalledTimes(2); + expect(sendMessage).not.toHaveBeenCalled(); + expect(host.isConnected).toBe(true); + changed(true); + await pending; + }); + + test("a grant during mounting removes the launcher even before a change notification", async () => { + core.hasCoreDataPermission.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + await waitForConsent(); + expect(document.querySelector("#ryd-consent-prompt")).toBeNull(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + test("waits for the active iframe request to finish before unloading it", async () => { const pending = waitForConsent({ alwaysExplain: true }); const { frame, host } = await mounted(); @@ -86,7 +152,7 @@ describe("consent overlay lifecycle", () => { }); test("ignores another window's messages and rechecks claimed grants", async () => { - const pending = waitForConsent(); + const pending = waitForConsent({ alwaysExplain: true }); const { frame, host } = await mounted(); message(frame, "ryd-consent-dismissed", {}); expect(host.hasAttribute("collapsed")).toBe(false); diff --git a/Extensions/e2e/extension/extension-premium-teaser-theme.e2e.js b/Extensions/e2e/extension/extension-premium-teaser-theme.e2e.js index e1a7b58..e1795d0 100644 --- a/Extensions/e2e/extension/extension-premium-teaser-theme.e2e.js +++ b/Extensions/e2e/extension/extension-premium-teaser-theme.e2e.js @@ -81,6 +81,9 @@ async function expectTeaserPalette(page, theme, legacyTokens) { await expect(control).toHaveCSS("color", hoverColor); await page.mouse.move(0, 0); await control.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(control).toBeFocused(); await expect(control).toHaveCSS("color", hoverColor); await control.blur(); }