diff --git a/README.md b/README.md index bac3044ea..543c50c5f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Zen is a firefox-based browser with the aim of pushing your productivity to a ne ### Firefox Versions -- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `153.0.4`! 🚀 +- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `154.0`! 🚀 - [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 154.0`! ### Contributing diff --git a/build/firefox-cache/l10n-last-commit-hash b/build/firefox-cache/l10n-last-commit-hash index 661d46b2c..593c41194 100644 --- a/build/firefox-cache/l10n-last-commit-hash +++ b/build/firefox-cache/l10n-last-commit-hash @@ -1 +1 @@ -4be2ba3142dc23601c46ab7e8268923f1939442d \ No newline at end of file +1fb0703fa9723fd9c400ad60d18e64ee4f9bf125 \ No newline at end of file diff --git a/src/zen/spaces/ZenGradientGenerator.mjs b/src/zen/spaces/ZenGradientGenerator.mjs index 20b66511f..28a47f31a 100644 --- a/src/zen/spaces/ZenGradientGenerator.mjs +++ b/src/zen/spaces/ZenGradientGenerator.mjs @@ -1496,14 +1496,14 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature { } getToolbarColor(isDarkMode = false, accentColor = undefined) { - const opacity = 0.8; + const opacity = 0.9; let baseColor = isDarkMode ? [255, 255, 255, opacity] : [0, 0, 0, opacity]; // Default toolbar - if (accentColor && this.canBeTransparent) { + if (accentColor) { // Blend a bit with the accent color to make it more visible baseColor = this.blendColors( accentColor, baseColor.slice(0, 3), - 10 + this.canBeTransparent ? 15 : 5 ).concat(opacity); } return baseColor; diff --git a/src/zen/tests/mochitests/shell/browser.toml b/src/zen/tests/mochitests/shell/browser.toml index b3c5bb1ca..81688111f 100644 --- a/src/zen/tests/mochitests/shell/browser.toml +++ b/src/zen/tests/mochitests/shell/browser.toml @@ -104,6 +104,11 @@ skip-if = [ "tsan", # Bug 1429950, Bug 1583315, Bug 1696109, Bug 1701449 ] +["browser_pinToTaskbarTelemetry.js"] +run-if = [ + "os == 'win'", +] + ["browser_processAUMID.js"] run-if = [ "os == 'win'", @@ -118,6 +123,12 @@ run-if = [ ] tags = "os_integration" +["browser_setDefaultPDFHandler_mac.js"] +run-if = [ + "os == 'mac'", +] +tags = "os_integration" + ["browser_setDefaultProtocolHandler.js"] run-if = [ "os == 'win'", diff --git a/src/zen/tests/mochitests/shell/browser_1119088.js b/src/zen/tests/mochitests/shell/browser_1119088.js index 2f466c92d..f79c70cd4 100644 --- a/src/zen/tests/mochitests/shell/browser_1119088.js +++ b/src/zen/tests/mochitests/shell/browser_1119088.js @@ -151,7 +151,7 @@ add_task(async function () { // Saves the file in ~/Pictures shellSvc.setDesktopBackground(image, 0, backgroundImage.leafName); - await BrowserTestUtils.waitForCondition(() => backgroundImage.exists()); + await TestUtils.waitForCondition(() => backgroundImage.exists()); info(`${backgroundImage.path} downloaded`); Assert.ok( FileUtils.File(backgroundImage.path).exists(), diff --git a/src/zen/tests/mochitests/shell/browser_createWindowsShortcut.js b/src/zen/tests/mochitests/shell/browser_createWindowsShortcut.js index 0b951bde2..d0fb56227 100644 --- a/src/zen/tests/mochitests/shell/browser_createWindowsShortcut.js +++ b/src/zen/tests/mochitests/shell/browser_createWindowsShortcut.js @@ -52,17 +52,23 @@ const gDirectoryServiceProvider = { QueryInterface: ChromeUtils.generateQI([Ci.nsIDirectoryServiceProvider]), }; -add_setup(() => { +add_setup(async () => { Services.dirsvc .QueryInterface(Ci.nsIDirectoryService) .registerProvider(gDirectoryServiceProvider); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.shell.shortcut.test", true]], + }); }); -registerCleanupFunction(() => { +registerCleanupFunction(async () => { gBase.remove(true); Services.dirsvc .QueryInterface(Ci.nsIDirectoryService) .unregisterProvider(gDirectoryServiceProvider); + + await SpecialPowers.popPrefEnv(); }); add_task(async function test_CreateWindowsShortcut() { diff --git a/src/zen/tests/mochitests/shell/browser_pinToTaskbarTelemetry.js b/src/zen/tests/mochitests/shell/browser_pinToTaskbarTelemetry.js new file mode 100644 index 000000000..d111bc1a5 --- /dev/null +++ b/src/zen/tests/mochitests/shell/browser_pinToTaskbarTelemetry.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ShellService: "moz-src:///browser/components/shell/ShellService.sys.mjs", +}); + +const kStubPref = "browser.shell.taskbar.test.pinWinRtStubResult"; + +add_setup(function () { + Services.fog.initializeFOG(); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref(kStubPref); + }); +}); + +// Success outcomes: the WinRT stub returns Ok with no fallback, so the promise +// resolves to the matching PinResult. +add_task(async function successCases() { + const cases = [ + ["success_pinned", Ci.nsIWindowsShellService.PINNED], + ["success_rejected", Ci.nsIWindowsShellService.REJECTED], + ["success_fire_and_forget", Ci.nsIWindowsShellService.UNKNOWN], + ]; + + for (let [stubValue, expected] of cases) { + Services.prefs.setCharPref(kStubPref, stubValue); + + Assert.equal(undefined, Glean.taskbar.pinWinrt.testGetValue()); + + let result = await ShellService.pinCurrentAppToTaskbar(false); + Assert.equal(result, expected, `resolved PinResult for ${stubValue}`); + + let events = Glean.taskbar.pinWinrt.testGetValue(); + Assert.equal(events.length, 1, `one pin_winrt event for ${stubValue}`); + Assert.equal( + events[0].extra.result, + stubValue, + "result extra matches pref" + ); + + Services.fog.testResetFOG(); + } +}); + +// Error outcomes: telemetry is recorded before the COM fallback runs. The +// fallback's resolve/reject is environment-dependent, so only assert telemetry. +add_task(async function errorCases() { + const errors = [ + "error_get_aumid", + "error_set_aumid", + "error_get_taskbar_manager", + "error_schedule_request_pin", + "error_request_pin", + ]; + + for (let stubValue of errors) { + Services.prefs.setCharPref(kStubPref, stubValue); + + Assert.equal(undefined, Glean.taskbar.pinWinrt.testGetValue()); + + try { + await ShellService.pinCurrentAppToTaskbar(false); + } catch (e) { + // The COM fallback may reject; the pin_winrt telemetry is already recorded. + } + + let events = Glean.taskbar.pinWinrt.testGetValue(); + Assert.equal(events.length, 1, `one pin_winrt event for ${stubValue}`); + Assert.equal( + events[0].extra.result, + stubValue, + "result extra matches pref" + ); + + Services.fog.testResetFOG(); + } +}); diff --git a/src/zen/tests/mochitests/shell/browser_processAUMID.js b/src/zen/tests/mochitests/shell/browser_processAUMID.js index cf98316f8..5d54e645f 100644 --- a/src/zen/tests/mochitests/shell/browser_processAUMID.js +++ b/src/zen/tests/mochitests/shell/browser_processAUMID.js @@ -17,11 +17,11 @@ add_task(async function test_processAUMID() { // This function will trigger the relevant code paths that // incorrectly changes the process AUMID on MSIX, prior to // Bug 1950734 being fixed - await ShellService.checkPinCurrentAppToTaskbarAsync(false); + await ShellService.pinToTaskbar(); is( - processAUMID, ShellService.checkCurrentProcessAUMIDForTesting(), + processAUMID, "The process AUMID should not be changed" ); }); diff --git a/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler.js b/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler.js index 3ffa6cb57..41a015e03 100644 --- a/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler.js +++ b/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler.js @@ -42,6 +42,9 @@ const launchModernSettingsDialogDefaultAppsStub = sinon.stub(); const shellStub = sinon.stub(ShellService, "shellService").value({ setDefaultBrowser: setDefaultStub, queryCurrentDefaultHandlerFor: queryCurrentDefaultHandlerForStub, + // setAsDefaultPDFHandler samples this for the recorded telemetry; the value + // doesn't matter for these assertions. + isDefaultHandlerFor: sinon.stub(), launchSetDefaultAppPicker: launchSetDefaultAppPickerStub, launchModernSettingsDialogDefaultApps: launchModernSettingsDialogDefaultAppsStub, diff --git a/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler_mac.js b/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler_mac.js new file mode 100644 index 000000000..dd7c1fc67 --- /dev/null +++ b/src/zen/tests/mochitests/shell/browser_setDefaultPDFHandler_mac.js @@ -0,0 +1,260 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// macOS-only checks for ShellService.setAsDefaultPDFHandler and the supporting +// isDefaultHandlerFor / canSetAsDefaultPDFHandler branches. On macOS these go +// through NSWorkspace; the native layer is mocked here. + +ChromeUtils.defineESModuleGetters(this, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +Assert.equal(AppConstants.platform, "macosx", "Platform is macOS"); + +const setAsDefaultHandlerForStub = sinon.stub().resolves(false); +const isDefaultHandlerForStub = sinon.stub().returns(false); +const isDefaultHandlerAWebBrowserForStub = sinon.stub().returns(false); + +const fakeShellService = { + canSetAsDefaultHandler: true, + setAsDefaultHandlerFor: setAsDefaultHandlerForStub, + isDefaultHandlerFor: isDefaultHandlerForStub, + isDefaultHandlerAWebBrowserFor: isDefaultHandlerAWebBrowserForStub, + QueryInterface: ChromeUtils.generateQI([]), +}; + +const shellStub = sinon + .stub(ShellService, "shellService") + .value(fakeShellService); + +registerCleanupFunction(() => { + shellStub.restore(); +}); + +function resetStubs() { + setAsDefaultHandlerForStub.resetHistory(); + isDefaultHandlerForStub.resetHistory(); + isDefaultHandlerAWebBrowserForStub.resetHistory(); +} + +function getAttemptEvent() { + const events = Glean.browser.setDefaultPdfHandlerAttempt.testGetValue(); + Assert.ok(events?.length, "Recorded a set_default_pdf_handler_attempt event"); + Assert.equal(events.length, 1, "Recorded exactly one attempt event"); + return events[0]; +} + +add_task(async function test_canSetAsDefaultPDFHandler() { + fakeShellService.canSetAsDefaultHandler = true; + Assert.ok( + ShellService.canSetAsDefaultPDFHandler, + "canSetAsDefaultPDFHandler is true when the native value is true" + ); + + try { + fakeShellService.canSetAsDefaultHandler = false; + Assert.ok( + !ShellService.canSetAsDefaultPDFHandler, + "canSetAsDefaultPDFHandler is false when the native value is false" + ); + } finally { + fakeShellService.canSetAsDefaultHandler = true; + } +}); + +add_task(async function test_isDefaultHandlerFor() { + isDefaultHandlerForStub.returns(true); + Assert.ok( + ShellService.isDefaultHandlerFor(".pdf"), + "isDefaultHandlerFor('.pdf') reflects the native result" + ); + Assert.ok( + isDefaultHandlerForStub.calledWith(".pdf"), + "Forwarded '.pdf' to the native isDefaultHandlerFor" + ); + + isDefaultHandlerForStub.returns(false); + Assert.ok( + !ShellService.isDefaultHandlerFor(".pdf"), + "isDefaultHandlerFor('.pdf') is false when Firefox is not the default" + ); + + isDefaultHandlerForStub.returns(true); + Assert.ok( + ShellService.isDefaultHandlerFor("https"), + "isDefaultHandlerFor('https') reflects the native result" + ); + Assert.ok( + isDefaultHandlerForStub.calledWith("https"), + "Forwarded 'https' to the native isDefaultHandlerFor" + ); + resetStubs(); +}); + +add_task(async function test_setAsDefaultPDFHandler_unsupported() { + Services.fog.testResetFOG(); + try { + fakeShellService.canSetAsDefaultHandler = false; + setAsDefaultHandlerForStub.resolves(true); + isDefaultHandlerForStub.returns(true); + + const result = await ShellService.setAsDefaultPDFHandler(); + Assert.strictEqual(result, false, "Resolves false when unsupported"); + Assert.ok( + setAsDefaultHandlerForStub.notCalled, + "Did not call the native setter when unsupported" + ); + Assert.ok( + !Glean.browser.setDefaultPdfHandlerAttempt.testGetValue(), + "Did not record an attempt event when unsupported" + ); + } finally { + fakeShellService.canSetAsDefaultHandler = true; + resetStubs(); + } +}); + +add_task(async function test_setAsDefaultPDFHandler_confirmed() { + Services.fog.testResetFOG(); + setAsDefaultHandlerForStub.resolves(true); + isDefaultHandlerForStub.returns(true); + + const result = await ShellService.setAsDefaultPDFHandler(); + Assert.strictEqual(result, true, "Resolves true when the user confirms"); + Assert.ok( + setAsDefaultHandlerForStub.calledOnce, + "Called the native setAsDefaultHandlerFor once" + ); + Assert.ok( + setAsDefaultHandlerForStub.calledWith(".pdf"), + "Forwarded '.pdf' to the native setAsDefaultHandlerFor" + ); + + const event = getAttemptEvent(); + Assert.equal( + event.extra.method, + "launch_services", + "Event method is launch_services" + ); + Assert.equal(event.extra.success, "true", "Event success is true"); + Assert.equal( + event.extra.result_is_default, + "true", + "Event result_is_default reflects isDefaultHandlerFor" + ); + resetStubs(); +}); + +add_task(async function test_setAsDefaultPDFHandler_recordsIndependentState() { + Services.fog.testResetFOG(); + // success (the native setter's result) and result_is_default (a separate + // isDefaultHandlerFor sample) come from different sources, so drive them to + // different values: a bug that conflated the two would pass every test where + // they happen to agree. + setAsDefaultHandlerForStub.resolves(true); + isDefaultHandlerForStub.returns(false); + + const result = await ShellService.setAsDefaultPDFHandler(); + Assert.strictEqual(result, true, "Resolves with the native setter's result"); + + const event = getAttemptEvent(); + Assert.equal( + event.extra.success, + "true", + "success reflects the native setter" + ); + Assert.equal( + event.extra.result_is_default, + "false", + "result_is_default reflects isDefaultHandlerFor, not success" + ); + resetStubs(); +}); + +add_task(async function test_setAsDefaultPDFHandler_declined() { + Services.fog.testResetFOG(); + setAsDefaultHandlerForStub.resolves(false); + isDefaultHandlerForStub.returns(false); + + const result = await ShellService.setAsDefaultPDFHandler(); + Assert.strictEqual(result, false, "Resolves false when the user declines"); + + const event = getAttemptEvent(); + Assert.equal(event.extra.success, "false", "Event success is false"); + Assert.equal( + event.extra.result_is_default, + "false", + "Event result_is_default is false" + ); + resetStubs(); +}); + +add_task(async function test_setAsDefaultPDFHandler_error() { + Services.fog.testResetFOG(); + setAsDefaultHandlerForStub.rejects(new Error("mock NSWorkspace failure")); + isDefaultHandlerForStub.returns(false); + + const result = await ShellService.setAsDefaultPDFHandler(); + Assert.strictEqual( + result, + false, + "Resolves false when the native call rejects" + ); + + const event = getAttemptEvent(); + Assert.equal(event.extra.success, "false", "Event success is false on error"); + resetStubs(); + setAsDefaultHandlerForStub.resolves(false); +}); + +add_task(async function test_setAsDefaultPDFHandler_onlyIfKnownBrowser() { + setAsDefaultHandlerForStub.resolves(true); + isDefaultHandlerForStub.returns(true); + + isDefaultHandlerAWebBrowserForStub.returns(true); + let result = await ShellService.setAsDefaultPDFHandler(true); + Assert.strictEqual( + result, + true, + "Resolves true when the handler is a browser" + ); + Assert.ok( + setAsDefaultHandlerForStub.called, + "Set the default when the current handler is a browser" + ); + Assert.ok( + isDefaultHandlerAWebBrowserForStub.calledWith(".pdf"), + "Forwarded '.pdf' to the native isDefaultHandlerAWebBrowserFor" + ); + resetStubs(); + + isDefaultHandlerAWebBrowserForStub.returns(false); + result = await ShellService.setAsDefaultPDFHandler(true); + Assert.strictEqual( + result, + false, + "Resolves false when the handler is not a browser" + ); + Assert.ok( + setAsDefaultHandlerForStub.notCalled, + "Did not set the default when the current handler is not a browser" + ); + resetStubs(); + + isDefaultHandlerAWebBrowserForStub.returns(false); + result = await ShellService.setAsDefaultPDFHandler(false); + Assert.strictEqual( + result, + true, + "Resolves true and sets unconditionally when onlyIfKnownBrowser is false" + ); + Assert.ok( + setAsDefaultHandlerForStub.called, + "Set the default unconditionally when onlyIfKnownBrowser is false" + ); + Assert.ok( + isDefaultHandlerAWebBrowserForStub.notCalled, + "Did not consult the browser check when onlyIfKnownBrowser is false" + ); + resetStubs(); +}); diff --git a/src/zen/tests/mochitests/shell/gtest/SetDefaultBrowserButtonTests.cpp b/src/zen/tests/mochitests/shell/gtest/SetDefaultBrowserButtonTests.cpp deleted file mode 100644 index d28d077b9..000000000 --- a/src/zen/tests/mochitests/shell/gtest/SetDefaultBrowserButtonTests.cpp +++ /dev/null @@ -1,237 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "mozilla/Maybe.h" -#include "mozilla/UniquePtr.h" -#include "WindowsDefaultBrowser.h" - -static bool RegWriteStringValue(HKEY aRoot, std::wstring_view aKey, - const mozilla::Maybe& aName, - std::wstring_view aValue) { - HKEY key; - LSTATUS ls{::RegCreateKeyExW(aRoot, aKey.data(), 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, - &key, nullptr)}; - - if (ls != ERROR_SUCCESS) { - return false; - } - - const BYTE* value{reinterpret_cast(aValue.data())}; - const DWORD size{static_cast((aValue.size() + 1) * sizeof(WCHAR))}; - ls = ::RegSetValueExW(key, aName ? aName->c_str() : nullptr, 0, REG_SZ, value, - size); - - ::RegCloseKey(key); - - return (ls == ERROR_SUCCESS); -} - -static bool RegDeleteValue(HKEY aRoot, std::wstring_view aKey, - std::wstring_view aName) { - HKEY key; - LSTATUS ls{ - ::RegOpenKeyExW(HKEY_CURRENT_USER, aKey.data(), 0, KEY_SET_VALUE, &key)}; - - if (ls == ERROR_SUCCESS) { - ::RegDeleteValueW(key, aName.data()); - ::RegCloseKey(key); - return true; - } - - return false; -} - -static bool RegDeleteKey(HKEY aRoot, std::wstring_view aKey) { - LSTATUS ls{::RegDeleteTreeW(HKEY_CURRENT_USER, aKey.data())}; - - return (ls == ERROR_SUCCESS); -} - -static void TerminateProcessById(DWORD aProcessId) { - HANDLE process{ - ::OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, aProcessId)}; - if (process) { - ::TerminateProcess(process, 0); - ::WaitForSingleObject(process, INFINITE); - ::CloseHandle(process); - } -} - -static void TerminateProcessByName(LPCWSTR aProcessName) { - HANDLE processes{::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)}; - if (processes == INVALID_HANDLE_VALUE) { - return; - } - - PROCESSENTRY32W process{}; - process.dwSize = sizeof(PROCESSENTRY32W); - if (::Process32FirstW(processes, &process)) { - do { - if (::_wcsicmp(process.szExeFile, aProcessName) == 0) { - TerminateProcessById(process.th32ProcessID); - } - } while (::Process32NextW(processes, &process)); - } - ::CloseHandle(processes); -} - -static UIWindowElement WaitForSetDefaultBrowserButton() { - const int kMaxAttempts{40}; - const DWORD kRetryDelayMs{500}; - for (int i{0}; i < kMaxAttempts; ++i) { - auto [window, button]{FindSetDefaultBrowserButton()}; - if (window && button) { - return {window, button}; - } - Sleep(kRetryDelayMs); - } - return {}; -} - -static bool IsElementFocus(const UIElement& aElement) { - BOOL isFocus{FALSE}; - aElement->get_CurrentHasKeyboardFocus(&isFocus); - return isFocus; -} - -class SetDefaultBrowserButtonTests : public ::testing::Test { - protected: - static void SetUpTestSuite() { - ASSERT_TRUE(GetAppRegName(sAppRegName)); - RegisterAsBrowser(); - } - - static void TearDownTestSuite() { UnregisterAsBrowser(); } - - void TearDown() override { TerminateSystemSettings(); } - - void TerminateSystemSettings() { - LPCWSTR processName{L"SystemSettings.exe"}; - TerminateProcessByName(processName); - } - - private: - static void RegisterAsBrowser() { - const std::wstring appRegName{sAppRegName.get()}; - - WCHAR exePath[MAX_PATH]; - ::GetModuleFileNameW(nullptr, exePath, MAX_PATH); - - const std::wstring appName{L"Firefox Test"}; - - // clang-format off - // HKEY_CURRENT_USER\Software\RegisteredApplications - { - const std::wstring key{L"Software\\RegisteredApplications"}; - const std::wstring value{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(appRegName), value)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), appName)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\Capabilities - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; - const std::wstring name{L"ApplicationDescription"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appName)); - } - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; - const std::wstring name{L"ApplicationIcon"}; - const std::wstring value{std::wstring(exePath) + L",0"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), value)); - } - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; - const std::wstring name{L"ApplicationName"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appName)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\Capabilities\URLAssociations - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities\\URLAssociations"}; - const std::wstring name{L"http"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appRegName)); - } - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities\\URLAssociations"}; - const std::wstring name{L"https"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appRegName)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\DefaultIcon - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\DefaultIcon"}; - const std::wstring value{std::wstring(exePath) + L",0"}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\shell\open\command - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\shell\\open\\command"}; - const std::wstring value{L"\"" + std::wstring(exePath) + L"\""}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); - } - // HKEY_CURRENT_USER\Software\Classes\Firefox-XYZ\shell\open\command - { - const std::wstring key{L"Software\\Classes\\" + appRegName + L"\\shell\\open\\command"}; - const std::wstring value{L"\"" + std::wstring(exePath) + L"\" -osint -url \"%1\""}; - ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); - } - // clang-format on - } - - static void UnregisterAsBrowser() { - const std::wstring appRegName{sAppRegName.get()}; - - // clang-format off - // HKEY_CURRENT_USER\Software\RegisteredApplications - { - const std::wstring key{L"Software\\RegisteredApplications"}; - ASSERT_TRUE(RegDeleteValue(HKEY_CURRENT_USER, key, appRegName)); - } - // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ - { - const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName}; - ASSERT_TRUE(RegDeleteKey(HKEY_CURRENT_USER, key)); - } - // HKEY_CURRENT_USER\Software\Classes\Firefox-XYZ - { - const std::wstring key{L"Software\\Classes\\" + appRegName}; - ASSERT_TRUE(RegDeleteKey(HKEY_CURRENT_USER, key)); - } - // clang-format on - } - - inline static mozilla::UniquePtr sAppRegName; -}; - -TEST_F(SetDefaultBrowserButtonTests, FindDefaultBrowserButton) { - ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); - - auto [window, button]{WaitForSetDefaultBrowserButton()}; - ASSERT_THAT(window, testing::NotNull()); - ASSERT_THAT(button, testing::NotNull()); -} - -TEST_F(SetDefaultBrowserButtonTests, FocusDefaultBrowserButton) { - ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); - - auto [window, button]{WaitForSetDefaultBrowserButton()}; - ASSERT_THAT(window, testing::NotNull()); - ASSERT_THAT(button, testing::NotNull()); - - FocusElement(window, button); - ASSERT_TRUE(IsElementFocus(button)); -} diff --git a/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.cpp b/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.cpp new file mode 100644 index 000000000..86759d7f8 --- /dev/null +++ b/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.cpp @@ -0,0 +1,220 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "WindowsDefaultBrowserTests.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mozilla/Maybe.h" +#include "mozilla/UniquePtr.h" +#include "WindowsDefaultBrowser.h" + +static bool RegDeleteKey(HKEY aRoot, std::wstring_view aKey) { + LSTATUS ls{::RegDeleteTreeW(HKEY_CURRENT_USER, aKey.data())}; + + return (ls == ERROR_SUCCESS); +} + +static bool RegDeleteValue(HKEY aRoot, std::wstring_view aKey, + std::wstring_view aName) { + HKEY key; + LSTATUS ls{ + ::RegOpenKeyExW(HKEY_CURRENT_USER, aKey.data(), 0, KEY_SET_VALUE, &key)}; + + if (ls == ERROR_SUCCESS) { + ::RegDeleteValueW(key, aName.data()); + ::RegCloseKey(key); + return true; + } + + return false; +} + +static bool RegWriteStringValue(HKEY aRoot, std::wstring_view aKey, + const mozilla::Maybe& aName, + std::wstring_view aValue) { + HKEY key; + LSTATUS ls{::RegCreateKeyExW(aRoot, aKey.data(), 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, + &key, nullptr)}; + + if (ls != ERROR_SUCCESS) { + return false; + } + + const BYTE* value{reinterpret_cast(aValue.data())}; + const DWORD size{static_cast((aValue.size() + 1) * sizeof(WCHAR))}; + ls = ::RegSetValueExW(key, aName ? aName->c_str() : nullptr, 0, REG_SZ, value, + size); + + ::RegCloseKey(key); + + return (ls == ERROR_SUCCESS); +} + +static void TerminateProcessById(DWORD aProcessId) { + HANDLE process{ + ::OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, aProcessId)}; + if (process) { + ::TerminateProcess(process, 0); + ::WaitForSingleObject(process, INFINITE); + ::CloseHandle(process); + } +} + +static void TerminateProcessByName(LPCWSTR aProcessName) { + HANDLE processes{::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)}; + if (processes == INVALID_HANDLE_VALUE) { + return; + } + + PROCESSENTRY32W process{}; + process.dwSize = sizeof(PROCESSENTRY32W); + if (::Process32FirstW(processes, &process)) { + do { + if (::_wcsicmp(process.szExeFile, aProcessName) == 0) { + TerminateProcessById(process.th32ProcessID); + } + } while (::Process32NextW(processes, &process)); + } + ::CloseHandle(processes); +} + +void FindSetDefaultBrowserButtonTests::SetUpTestSuite() { + ASSERT_TRUE(GetAppRegName(sAppRegName)); + RegisterAsBrowser(); +} + +void FindSetDefaultBrowserButtonTests::TearDownTestSuite() { + UnregisterAsBrowser(); +} + +void FindSetDefaultBrowserButtonTests::TearDown() { TerminateSystemSettings(); } + +void FindSetDefaultBrowserButtonTests::TerminateSystemSettings() { + LPCWSTR processName{L"SystemSettings.exe"}; + TerminateProcessByName(processName); +} + +UIWindowElement +FindSetDefaultBrowserButtonTests::WaitForSetDefaultBrowserButton() { + const int kMaxAttempts{40}; + const DWORD kRetryDelayMs{500}; + for (int i{0}; i < kMaxAttempts; ++i) { + auto [window, button]{FindSetDefaultBrowserButton()}; + if (window && button) { + return {window, button}; + } + Sleep(kRetryDelayMs); + } + return {}; +} + +void FindSetDefaultBrowserButtonTests::RegisterAsBrowser() { + const std::wstring appRegName{sAppRegName.get()}; + + WCHAR exePath[MAX_PATH]; + ::GetModuleFileNameW(nullptr, exePath, MAX_PATH); + + const std::wstring appName{L"Firefox Test"}; + + // clang-format off + // HKEY_CURRENT_USER\Software\RegisteredApplications + { + const std::wstring key{L"Software\\RegisteredApplications"}; + const std::wstring value{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(appRegName), value)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), appName)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\Capabilities + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; + const std::wstring name{L"ApplicationDescription"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appName)); + } + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; + const std::wstring name{L"ApplicationIcon"}; + const std::wstring value{std::wstring(exePath) + L",0"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), value)); + } + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities"}; + const std::wstring name{L"ApplicationName"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appName)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\Capabilities\URLAssociations + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities\\URLAssociations"}; + const std::wstring name{L"http"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appRegName)); + } + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\Capabilities\\URLAssociations"}; + const std::wstring name{L"https"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Some(name), appRegName)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\DefaultIcon + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\DefaultIcon"}; + const std::wstring value{std::wstring(exePath) + L",0"}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ\shell\open\command + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName + L"\\shell\\open\\command"}; + const std::wstring value{L"\"" + std::wstring(exePath) + L"\""}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); + } + // HKEY_CURRENT_USER\Software\Classes\Firefox-XYZ\shell\open\command + { + const std::wstring key{L"Software\\Classes\\" + appRegName + L"\\shell\\open\\command"}; + const std::wstring value{L"\"" + std::wstring(exePath) + L"\" -osint -url \"%1\""}; + ASSERT_TRUE(RegWriteStringValue(HKEY_CURRENT_USER, key, mozilla::Nothing(), value)); + } + // clang-format on +} + +void FindSetDefaultBrowserButtonTests::UnregisterAsBrowser() { + const std::wstring appRegName{sAppRegName.get()}; + + // clang-format off + // HKEY_CURRENT_USER\Software\RegisteredApplications + { + const std::wstring key{L"Software\\RegisteredApplications"}; + ASSERT_TRUE(RegDeleteValue(HKEY_CURRENT_USER, key, appRegName)); + } + // HKEY_CURRENT_USER\Software\Clients\StartMenuInternet\Firefox-XYZ + { + const std::wstring key{L"Software\\Clients\\StartMenuInternet\\" + appRegName}; + ASSERT_TRUE(RegDeleteKey(HKEY_CURRENT_USER, key)); + } + // HKEY_CURRENT_USER\Software\Classes\Firefox-XYZ + { + const std::wstring key{L"Software\\Classes\\" + appRegName}; + ASSERT_TRUE(RegDeleteKey(HKEY_CURRENT_USER, key)); + } + // clang-format on +} + +mozilla::UniquePtr FindSetDefaultBrowserButtonTests::sAppRegName; + +TEST_F(FindSetDefaultBrowserButtonTests, ButtonFound) { + ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); + + auto [window, button]{WaitForSetDefaultBrowserButton()}; + ASSERT_THAT(window, testing::NotNull()); + ASSERT_THAT(button, testing::NotNull()); +} diff --git a/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.h b/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.h new file mode 100644 index 000000000..658fdf167 --- /dev/null +++ b/src/zen/tests/mochitests/shell/gtest/WindowsDefaultBrowserTests.h @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef WINDOWS_DEFAULT_BROWSER_TESTS_H_ +#define WINDOWS_DEFAULT_BROWSER_TESTS_H_ + +#include + +#include + +#include "mozilla/UniquePtr.h" +#include "WindowsDefaultBrowser.h" + +class FindSetDefaultBrowserButtonTests : public ::testing::Test { + protected: + static void SetUpTestSuite(); + static void TearDownTestSuite(); + void TearDown() override; + void TerminateSystemSettings(); + static UIWindowElement WaitForSetDefaultBrowserButton(); + + private: + static void RegisterAsBrowser(); + static void UnregisterAsBrowser(); + + static mozilla::UniquePtr sAppRegName; +}; + +#endif diff --git a/src/zen/tests/mochitests/shell/gtest/WindowsUIElementTests.cpp b/src/zen/tests/mochitests/shell/gtest/WindowsUIElementTests.cpp new file mode 100644 index 000000000..17fd1227c --- /dev/null +++ b/src/zen/tests/mochitests/shell/gtest/WindowsUIElementTests.cpp @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include +#include + +#include +#include +#include +#include + +#include "WindowsDefaultBrowser.h" +#include "WindowsDefaultBrowserTests.h" +#include "WindowsUIElement.h" +#include "mozilla/RefPtr.h" +#include "mozilla/SpinEventLoopUntil.h" +#include "mozilla/WindowsVersion.h" +#include "mozilla/gtest/MozAssertions.h" +#include "nsCOMPtr.h" +#include "nsISerialEventTarget.h" +#include "nsITimer.h" +#include "nsLiteralString.h" +#include "nsThreadUtils.h" + +static bool IsElementFocus(const UIElement& aElement) { + BOOL isFocus{FALSE}; + aElement->get_CurrentHasKeyboardFocus(&isFocus); + return isFocus; +} + +class WindowsUIElementTests : public FindSetDefaultBrowserButtonTests {}; + +TEST_F(WindowsUIElementTests, DefaultBrowserButtonFocused) { + ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); + + auto [window, button]{WaitForSetDefaultBrowserButton()}; + ASSERT_THAT(window, testing::NotNull()); + ASSERT_THAT(button, testing::NotNull()); + + RefPtr element{ + new mozilla::WindowsUIElement(window, button)}; + element->Focus(); + + ASSERT_TRUE(IsElementFocus(button)); +} + +TEST_F(WindowsUIElementTests, DefaultBrowserButtonStopsMoving) { + if (!mozilla::IsWin11OrLater()) { + // This test is intended to run only on Win11 since it's part of the feature + // that displays the Kit image behind the Set Default Browser button + return; + } + + ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); + + auto [window, button]{WaitForSetDefaultBrowserButton()}; + ASSERT_THAT(window, testing::NotNull()); + ASSERT_THAT(button, testing::NotNull()); + + RefPtr element{ + new mozilla::WindowsUIElement(window, button)}; + + nsCOMPtr queue; + ASSERT_NS_SUCCEEDED(NS_CreateBackgroundTaskQueue( + "WindowsUIElementTests::DefaultBrowserButtonStopsMovingQueue", + getter_AddRefs(queue))); + + int ticks{0}; + bool elementIsMoving{true}; + bool done{false}; + nsCOMPtr timer; + auto callback{[element, &ticks, &elementIsMoving, &done](nsITimer* aTimer) { + elementIsMoving = element->IsMoving().valueOr(true); + constexpr int kMaxTicks{10}; + if (!elementIsMoving || ++ticks >= kMaxTicks) { + aTimer->Cancel(); + NS_DispatchToMainThread(NS_NewRunnableFunction( + "WindowsUIElementTests::DefaultBrowserButtonStopsMovingDone", + [&done] { done = true; })); + } + }}; + + const uint32_t kDelayMs{500}; + ASSERT_NS_SUCCEEDED(NS_NewTimerWithCallback( + getter_AddRefs(timer), callback, kDelayMs, nsITimer::TYPE_REPEATING_SLACK, + "WindowsUIElementTests::DefaultBrowserButtonStopsMovingTimer"_ns, queue)); + + mozilla::SpinEventLoopUntil( + "WindowsUIElementTests::DefaultBrowserButtonStopsMovingLoop"_ns, + [&done] { return bool(done); }); + + ASSERT_FALSE(elementIsMoving); +} diff --git a/src/zen/tests/mochitests/shell/gtest/WindowsUIOverlayImageTests.cpp b/src/zen/tests/mochitests/shell/gtest/WindowsUIOverlayImageTests.cpp new file mode 100644 index 000000000..e9c455a31 --- /dev/null +++ b/src/zen/tests/mochitests/shell/gtest/WindowsUIOverlayImageTests.cpp @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include +#include + +#include +#include +#include + +#include "WindowsUIOverlayImage.h" +#include "WindowsDefaultBrowser.h" +#include "WindowsDefaultBrowserTests.h" +#include "WindowsUIElement.h" +#include "mozilla/RefPtr.h" +#include "mozilla/SpinEventLoopUntil.h" +#include "mozilla/SyncRunnable.h" +#include "mozilla/WindowsVersion.h" +#include "mozilla/gtest/MozAssertions.h" +#include "nsCOMPtr.h" +#include "nsISerialEventTarget.h" +#include "nsISupportsImpl.h" +#include "nsITimer.h" +#include "nsLiteralString.h" +#include "nsThreadUtils.h" + +namespace { + +class CoverWindow final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(CoverWindow) + + static RefPtr Create(HWND aWindow) { + RECT rect{}; + if (!GetWindowRect(aWindow, &rect)) { + return nullptr; + } + + constexpr LPCWSTR kWindowClassName{L"CoverWindow"}; + WNDCLASSEXW windowClass{sizeof(windowClass)}; + windowClass.lpfnWndProc = DefWindowProcW; + windowClass.hInstance = GetModuleHandleW(nullptr); + windowClass.lpszClassName = kWindowClassName; + RegisterClassExW(&windowClass); + + HWND window{nullptr}; + mozilla::SyncRunnable::DispatchToThread( + mozilla::GetMainThreadSerialEventTarget(), + NS_NewRunnableFunction("CoverWindow::Create", [&window, rect] { + window = CreateWindowExW( + WS_EX_TOPMOST, kWindowClassName, nullptr, WS_POPUP, rect.left, + rect.top, rect.right - rect.left, rect.bottom - rect.top, nullptr, + nullptr, GetModuleHandleW(nullptr), nullptr); + })); + if (!window) { + return nullptr; + } + + ShowWindow(window, SW_SHOWNOACTIVATE); + + return RefPtr{new CoverWindow(window)}; + } + + // Non-copyable and non-movable + CoverWindow(const CoverWindow&) = delete; + CoverWindow(CoverWindow&&) = delete; + CoverWindow& operator=(const CoverWindow&) = delete; + CoverWindow& operator=(CoverWindow&&) = delete; + + private: + explicit CoverWindow(HWND aWindow) : mWindow{aWindow} {} + + ~CoverWindow() { + if (mWindow) { + NS_DispatchToMainThread(NS_NewRunnableFunction( + "CoverWindow::DestroyWindow", + [window = mWindow] { DestroyWindow(window); })); + mWindow = nullptr; + } + } + + HWND mWindow; +}; + +} // namespace + +class WindowsUIOverlayImageTests : public FindSetDefaultBrowserButtonTests {}; + +TEST_F(WindowsUIOverlayImageTests, OverlayHiddenWhenCovered) { + if (!mozilla::IsWin11OrLater()) { + // This test is intended to run only on Win11 since it's part of the feature + // that displays the Kit image behind the Set Default Browser button + return; + } + + ASSERT_TRUE(LaunchModernSettingsDialogDefaultApps()); + + auto [window, button]{WaitForSetDefaultBrowserButton()}; + ASSERT_THAT(window, testing::NotNull()); + ASSERT_THAT(button, testing::NotNull()); + + RefPtr element{ + new mozilla::WindowsUIElement(window, button)}; + + nsCOMPtr queue; + ASSERT_NS_SUCCEEDED(NS_CreateBackgroundTaskQueue( + "WindowsUIOverlayImageTests::OverlayHiddenWhenCoveredQueue", + getter_AddRefs(queue))); + + bool elementIsMoving{true}; + RefPtr overlayImage; + bool overlayImageIsVisibleBefore{false}; + RefPtr coverWindow; + bool overlayImageIsVisibleAfter{true}; + bool done{false}; + int ticks{0}; + int state{0}; + nsCOMPtr timer; + auto callback{[element, &elementIsMoving, &overlayImage, + &overlayImageIsVisibleBefore, window, &coverWindow, + &overlayImageIsVisibleAfter, &done, &ticks, + &state](nsITimer* aTimer) { + bool finished{false}; + switch (state) { + case 0: + // Wait until the element stops moving + elementIsMoving = element->IsMoving().valueOr(true); + if (!elementIsMoving) { + ++state; + } + break; + case 1: + // Create the overlay image + overlayImage = element->CreateOverlayImage( + mozilla::WindowsUIOverlayImage::DisplayMode::Static); + finished = !overlayImage; + ++state; + break; + case 2: + // The overlay image is visible before anything covers it + overlayImageIsVisibleBefore = overlayImage->IsVisible(); + if (overlayImageIsVisibleBefore) { + ++state; + } + break; + case 3: + // Cover the Windows Settings window + coverWindow = CoverWindow::Create(window); + finished = !coverWindow; + ++state; + break; + case 4: + // The overlay image shouldn't be visible + overlayImageIsVisibleAfter = overlayImage->IsVisible(); + finished = true; + break; + } + + constexpr int kMaxTicks{40}; + if (finished || ++ticks >= kMaxTicks) { + aTimer->Cancel(); + NS_DispatchToMainThread(NS_NewRunnableFunction( + "WindowsUIOverlayImageTests::OverlayHiddenWhenCoveredDone", + [&done] { done = true; })); + } + }}; + + const uint32_t kDelayMs{500}; + ASSERT_NS_SUCCEEDED(NS_NewTimerWithCallback( + getter_AddRefs(timer), callback, kDelayMs, nsITimer::TYPE_REPEATING_SLACK, + "WindowsUIOverlayImageTests::OverlayHiddenWhenCoveredTimer"_ns, queue)); + + mozilla::SpinEventLoopUntil( + "WindowsUIOverlayImageTests::OverlayHiddenWhenCoveredLoop"_ns, + [&done] { return bool(done); }); + + ASSERT_FALSE(elementIsMoving); + ASSERT_THAT(overlayImage, testing::NotNull()); + ASSERT_TRUE(overlayImageIsVisibleBefore); + ASSERT_THAT(coverWindow, testing::NotNull()); + ASSERT_FALSE(overlayImageIsVisibleAfter); +} + +TEST(GetFrameDurationTests, KitImageFirstFrameDurationIs40ms) +{ + nsCOMPtr imageFile{mozilla::GetImageFile()}; + ASSERT_THAT(imageFile, testing::NotNull()); + + RefPtr factory{mozilla::CreateWICImagingFactory()}; + ASSERT_THAT(factory, testing::NotNull()); + + RefPtr decoder{ + mozilla::CreateWICBitmapDecoder(factory, imageFile)}; + ASSERT_THAT(decoder, testing::NotNull()); + + RefPtr frame; + constexpr UINT kFrameNumber{0}; + ASSERT_TRUE( + SUCCEEDED(decoder->GetFrame(kFrameNumber, getter_AddRefs(frame)))); + + const mozilla::TimeDuration kSentinelFrameDuration{ + mozilla::TimeDuration::FromMilliseconds(999)}; + const mozilla::TimeDuration duration{ + mozilla::GetFrameDuration(frame, kSentinelFrameDuration)}; + + // Kit image (kit.gif) frame duration is 40 ms + const mozilla::TimeDuration kFrameDuration{ + mozilla::TimeDuration::FromMilliseconds(40)}; + + EXPECT_EQ(duration, kFrameDuration); +} + +class ComputeAdvancedFrameTests : public testing::Test { + protected: + const mozilla::TimeDuration mFrameDuration{ + mozilla::TimeDuration::FromMilliseconds(25)}; + + const std::vector mFrames{ + 4, mozilla::WindowsUIOverlayImage::Frame{{}, mFrameDuration}}; +}; + +TEST_F(ComputeAdvancedFrameTests, DoesNotAdvanceWhenTimeBelowFrameDuration) { + mozilla::TimeDuration accumulated{ + mozilla::TimeDuration::FromMilliseconds(20)}; + + size_t frame{mozilla::ComputeAdvancedFrame(mFrames, 0, accumulated)}; + + EXPECT_EQ(frame, 0u); + EXPECT_EQ(accumulated, mozilla::TimeDuration::FromMilliseconds(20)); +} + +TEST_F(ComputeAdvancedFrameTests, AdvancesSingleFrame) { + mozilla::TimeDuration accumulated{ + mozilla::TimeDuration::FromMilliseconds(30)}; + + size_t frame{mozilla::ComputeAdvancedFrame(mFrames, 0, accumulated)}; + + EXPECT_EQ(frame, 1u); + EXPECT_EQ(accumulated, + mozilla::TimeDuration::FromMilliseconds(30) - mFrameDuration); +} + +TEST_F(ComputeAdvancedFrameTests, AdvancesMultipleFramesInOneCall) { + mozilla::TimeDuration accumulated{ + mozilla::TimeDuration::FromMilliseconds(60)}; + + size_t frame{mozilla::ComputeAdvancedFrame(mFrames, 0, accumulated)}; + + EXPECT_EQ(frame, 2u); + EXPECT_EQ(accumulated, + mozilla::TimeDuration::FromMilliseconds(60) - mFrameDuration * 2); +} + +TEST_F(ComputeAdvancedFrameTests, DoesNotAdvancePastLastFrame) { + mozilla::TimeDuration accumulated{ + mozilla::TimeDuration::FromMilliseconds(1000)}; + + size_t frame{mozilla::ComputeAdvancedFrame(mFrames, 0, accumulated)}; + + EXPECT_EQ(frame, 3u); + // The time for the frames not reached is left untouched + EXPECT_EQ(accumulated, + mozilla::TimeDuration::FromMilliseconds(1000) - mFrameDuration * 3); +} + +TEST_F(ComputeAdvancedFrameTests, StaysOnLastFrame) { + mozilla::TimeDuration accumulated{ + mozilla::TimeDuration::FromMilliseconds(1000)}; + + size_t frame{mozilla::ComputeAdvancedFrame(mFrames, 3, accumulated)}; + + EXPECT_EQ(frame, 3u); + EXPECT_EQ(accumulated, mozilla::TimeDuration::FromMilliseconds(1000)); +} diff --git a/src/zen/tests/mochitests/shell/gtest/moz.build b/src/zen/tests/mochitests/shell/gtest/moz.build index e8905d28f..ebf164e4a 100644 --- a/src/zen/tests/mochitests/shell/gtest/moz.build +++ b/src/zen/tests/mochitests/shell/gtest/moz.build @@ -1,14 +1,16 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. - -if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": - LOCAL_INCLUDES += ["/browser/components/shell"] - - UNIFIED_SOURCES += [ - "LimitedAccessFeatureTests.cpp", - "SetDefaultBrowserButtonTests.cpp", - "ShellLinkTests.cpp", - ] - -FINAL_LIBRARY = "xul-gtest" +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + LOCAL_INCLUDES += ["/browser/components/shell"] + + UNIFIED_SOURCES += [ + "LimitedAccessFeatureTests.cpp", + "ShellLinkTests.cpp", + "WindowsDefaultBrowserTests.cpp", + "WindowsUIElementTests.cpp", + "WindowsUIOverlayImageTests.cpp", + ] + +FINAL_LIBRARY = "xul-gtest" diff --git a/src/zen/tests/mochitests/shell/unit/test_customIconManager.js b/src/zen/tests/mochitests/shell/unit/test_customIconManager.js new file mode 100644 index 000000000..5e752dab5 --- /dev/null +++ b/src/zen/tests/mochitests/shell/unit/test_customIconManager.js @@ -0,0 +1,901 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +// This is an xpcshell test, but a sibling browser.toml makes eslint apply the +// browser-test env, where TestUtils is a predefined global. It isn't one in +// xpcshell (there's no head.js here to import it either), so the import is +// required. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const { + CustomIconManager, + ICON_CATALOG, + resolvePreview, + resolveResourceId, + OS_LIGHT, + OS_DARK, +} = ChromeUtils.importESModule( + "moz-src:///browser/components/shell/CustomIconManager.sys.mjs" +); +// Importing this module constructs the toolkit profile service as a side effect, +// which requires setupProfileService() to have run, so it must stay lazy and +// only be touched from add_setup() onwards. +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SelectableProfileService: + "resource:///modules/profiles/SelectableProfileService.sys.mjs", +}); + +const PREF_ICON_ID = "browser.shell.customIcon.id"; +const TEST_AUMID = "Test.Firefox.AUMID"; +const TEST_SHORTCUTS = ["C:\\fake\\Desktop\\Nightly.lnk"]; +const RETRO_RESOURCE_ID = ICON_CATALOG.retro2004.iconResourceId; + +// CustomIconManager.apply() refuses to run on MSIX (packaged) builds, so on the +// MSIX CI job every task except the MSIX-specific one (which fakes the +// condition itself and runs everywhere) is skipped. +const ON_MSIX = Services.sysinfo.getProperty("hasWinPackageId"); + +// add_task() mutates the options object it is handed (tagging it isTask), so +// each call needs its own fresh object rather than a shared one. +function skipOnMsix() { + return { skip_if: () => ON_MSIX }; +} + +function exePath() { + return Services.dirsvc.get("XREExeF", Ci.nsIFile).path; +} + +let shellServiceMock = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWindowsShellService]), + enumerateInstallShortcuts: sinon.stub(), + setShortcutsIcon: sinon.stub(), + createShortcut: sinon.stub(), +}; + +let winTaskbarMock = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWinTaskbar]), + setAllWindowIcons: sinon.stub(), + refreshTaskbarButtons: sinon.stub(), + get defaultGroupId() { + return TEST_AUMID; + }, +}; + +// ensureAppliedOrRevert() awaits SelectableProfileService.init() on the startup +// path so the shared custom-icon pref can finish loading from the profiles +// database before it reconciles. We stub it here so these unit tests don't spin up +// the real profiles machinery. +let spsInitStub; + +// Reset stub history + default behaviour, clear the pref, and drop any recorded +// Glean values before each task. +function resetMocks() { + shellServiceMock.enumerateInstallShortcuts.reset(); + shellServiceMock.enumerateInstallShortcuts.resolves(TEST_SHORTCUTS.slice()); + shellServiceMock.setShortcutsIcon.reset(); + shellServiceMock.setShortcutsIcon.resolves(); + shellServiceMock.createShortcut.reset(); + shellServiceMock.createShortcut.resolves(); + winTaskbarMock.setAllWindowIcons.reset(); + winTaskbarMock.refreshTaskbarButtons.reset(); + spsInitStub.reset(); + spsInitStub.resolves(); + Services.prefs.clearUserPref(PREF_ICON_ID); + Services.fog.testResetFOG(); +} + +// The single "changed" event recorded since the last reset, or undefined if +// none. Fails if more than one was recorded (each task exercises one change). +function singleChangedEvent() { + let events = Glean.customIcon.changed.testGetValue() ?? []; + Assert.lessOrEqual(events.length, 1, "at most one changed event recorded"); + return events[0]; +} + +// Since we're importing SelectableProfileService for this test, we lift some of +// the setup from toolkit/profile/test/xpcshell/head.js which lets the service +// be imported and executed in debug xpcshell tests. +function setupProfileService() { + let profD = do_get_profile(); + + let dataHome = profD.clone(); + dataHome.append("data"); + dataHome.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + + let dataHomeLocal = profD.clone(); + dataHomeLocal.append("local"); + dataHomeLocal.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + + let xreDirProvider = Cc["@mozilla.org/xre/directory-provider;1"].getService( + Ci.nsIXREDirProvider + ); + xreDirProvider.setUserDataDirectory(dataHome, false); + xreDirProvider.setUserDataDirectory(dataHomeLocal, true); +} + +add_setup(function () { + setupProfileService(); + Services.fog.initializeFOG(); + + let shellCid = MockRegistrar.register( + "@mozilla.org/browser/shell-service;1", + shellServiceMock + ); + let taskbarCid = MockRegistrar.register( + "@mozilla.org/windows-taskbar;1", + winTaskbarMock + ); + + spsInitStub = sinon.stub(lazy.SelectableProfileService, "init").resolves(); + + registerCleanupFunction(() => { + spsInitStub.restore(); + MockRegistrar.unregister(taskbarCid); + MockRegistrar.unregister(shellCid); + Services.prefs.clearUserPref(PREF_ICON_ID); + }); +}); + +/** + * This test verifies that apply() enumerates shortcuts by the default AUMID, + * writes the catalog resource ID (positive, un-negated) and the executable + * path to the matching shortcuts, sets the runtime window icon, and records + * the pref. + */ +add_task( + skipOnMsix(), + async function test_apply_updates_shortcuts_pref_and_runtime() { + resetMocks(); + + await CustomIconManager.apply("retro2004"); + + Assert.ok( + shellServiceMock.enumerateInstallShortcuts.calledOnceWithExactly( + TEST_AUMID + ), + "enumerateInstallShortcuts called once with the default AUMID" + ); + + Assert.ok( + shellServiceMock.setShortcutsIcon.calledOnce, + "setShortcutsIcon called once" + ); + let [shortcuts, iconPath, resourceId] = + shellServiceMock.setShortcutsIcon.getCall(0).args; + Assert.deepEqual( + shortcuts, + TEST_SHORTCUTS, + "passed the enumerated shortcuts through" + ); + Assert.equal(iconPath, exePath(), "icon source is the running executable"); + Assert.equal( + resourceId, + RETRO_RESOURCE_ID, + "passed the catalog resource ID as-is (negation happens in C++, not JS)" + ); + + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(RETRO_RESOURCE_ID), + "runtime window icon set to the retro resource ID" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "retro2004", + "pref records the applied id" + ); + + let event = singleChangedEvent(); + Assert.ok(event, "a changed event was recorded"); + Assert.equal(event.category, "custom_icon", "event category"); + Assert.equal(event.name, "changed", "event name"); + Assert.equal( + event.extra.icon_id, + "retro2004", + "changed event carries the applied id" + ); + } +); + +/** + * This test verifies that apply() rejects when given an id absent from the + * catalog, without touching any shortcut or runtime state or the pref. + */ +add_task(skipOnMsix(), async function test_apply_unknown_id_throws() { + resetMocks(); + + await Assert.rejects( + CustomIconManager.apply("does-not-exist"), + /Unknown icon id/, + "apply rejects for an unknown catalog id" + ); + + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "no shortcut work attempted for an unknown id" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.notCalled, + "no runtime work attempted for an unknown id" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "", + "pref left untouched" + ); +}); + +/** + * This test verifies that apply() throws on MSIX (packaged) builds, where the + * feature is unsupported, without touching shortcuts, the runtime icon, or the + * pref. + */ +add_task(async function test_apply_throws_on_msix() { + resetMocks(); + + // Fake an MSIX build by flipping the sysinfo property the manager checks. + // nsSystemInfo is a writable property bag, so set it directly and restore it. + let bag = Services.sysinfo.QueryInterface(Ci.nsIWritablePropertyBag2); + let original = bag.getProperty("hasWinPackageId"); + bag.setPropertyAsBool("hasWinPackageId", true); + + try { + await Assert.rejects( + CustomIconManager.apply("retro2004"), + /MSIX/, + "apply rejects on an MSIX build" + ); + + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "no shortcut work attempted on MSIX" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.notCalled, + "no runtime work attempted on MSIX" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "", + "pref left untouched on MSIX" + ); + } finally { + bag.setPropertyAsBool("hasWinPackageId", original); + } +}); + +/** + * This test verifies that revert() resets matching shortcuts to the + * executable's default icon (resource ID 0), clears the runtime override, and + * clears the pref. + */ +add_task( + skipOnMsix(), + async function test_revert_resets_shortcuts_pref_and_runtime() { + resetMocks(); + Services.prefs.setStringPref(PREF_ICON_ID, "retro2004"); + + await CustomIconManager.revert(); + + Assert.ok( + shellServiceMock.setShortcutsIcon.calledOnce, + "setShortcutsIcon called once" + ); + let [, iconPath, resourceId] = + shellServiceMock.setShortcutsIcon.getCall(0).args; + Assert.equal(iconPath, exePath(), "reverts using the executable path"); + Assert.equal( + resourceId, + 0, + "resource ID 0 selects the executable's default icon" + ); + + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(0), + "runtime window icon cleared (0)" + ); + Assert.ok(!Services.prefs.prefHasUserValue(PREF_ICON_ID), "pref cleared"); + + let event = singleChangedEvent(); + Assert.ok(event, "reverting from a custom icon records a changed event"); + Assert.equal( + event.extra.icon_id, + "default", + "revert records the default id as the new selection" + ); + } +); + +/** + * This test verifies that when enumeration matches no shortcuts, apply() skips + * setShortcutsIcon but still applies the runtime icon and records the pref, so + * the running window updates even though no shortcut could be changed. + */ +add_task(skipOnMsix(), async function test_apply_no_matching_shortcuts() { + resetMocks(); + shellServiceMock.enumerateInstallShortcuts.resolves([]); + + // Must not throw even though nothing matched. + await CustomIconManager.apply("retro2004"); + + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "setShortcutsIcon not called when enumeration matched nothing" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(RETRO_RESOURCE_ID), + "runtime icon still applied even though no shortcut changed" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "retro2004", + "pref still recorded" + ); + Assert.equal( + singleChangedEvent()?.extra.icon_id, + "retro2004", + "changed event still recorded even though no shortcut matched" + ); +}); + +/** + * This test verifies that when setShortcutsIcon rejects, apply() logs and + * swallows the failure rather than throwing, and still applies the runtime + * icon and pref. + */ +add_task( + skipOnMsix(), + async function test_apply_shortcut_write_failure_is_swallowed() { + resetMocks(); + shellServiceMock.setShortcutsIcon.rejects( + Components.Exception( + "mock setShortcutsIcon failure", + Cr.NS_ERROR_NOT_AVAILABLE + ) + ); + + // A shortcut-write failure is logged, not thrown. + await CustomIconManager.apply("retro2004"); + + Assert.ok( + shellServiceMock.setShortcutsIcon.calledOnce, + "setShortcutsIcon was attempted" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(RETRO_RESOURCE_ID), + "runtime icon still applied despite the shortcut-write failure" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "retro2004", + "pref still recorded" + ); + Assert.equal( + singleChangedEvent()?.extra.icon_id, + "retro2004", + "changed event still recorded despite the shortcut-write failure" + ); + } +); + +/** + * This test verifies that re-applying the icon that is already active (as the + * theme observer and startup reconcile do) records no changed event, so the + * probe only fires on a genuine change of icon. + */ +add_task(skipOnMsix(), async function test_apply_same_id_records_no_change() { + resetMocks(); + + await CustomIconManager.apply("retro2004"); + Assert.ok(singleChangedEvent(), "first apply records a change"); + + Services.fog.testResetFOG(); + await CustomIconManager.apply("retro2004"); + Assert.equal( + Glean.customIcon.changed.testGetValue(), + undefined, + "re-applying the same id records no changed event" + ); +}); + +/** + * This test verifies that revert() over the already-default state (no custom + * icon active) records no changed event. + */ +add_task( + skipOnMsix(), + async function test_revert_when_default_records_nothing() { + resetMocks(); + + await CustomIconManager.revert(); + + Assert.equal( + Glean.customIcon.changed.testGetValue(), + undefined, + "reverting when already default records no changed event" + ); + } +); + +/** + * This test verifies that a rejected apply() (unknown id) records no changed + * event. + */ +add_task(skipOnMsix(), async function test_unknown_id_records_no_change() { + resetMocks(); + + await Assert.rejects( + CustomIconManager.apply("does-not-exist"), + /Unknown icon id/, + "apply rejects for an unknown catalog id" + ); + + Assert.equal( + Glean.customIcon.changed.testGetValue(), + undefined, + "no changed event recorded for a rejected apply" + ); +}); + +/** + * This test verifies that ensureAppliedOrRevert() records the current-icon + * string metric once at startup: the active id for a known custom icon, and + * "default" when no custom icon is set. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_records_current() { + resetMocks(); + Services.prefs.setStringPref(PREF_ICON_ID, "retro2004"); + + await CustomIconManager.ensureAppliedOrRevert(); + Assert.equal( + Glean.customIcon.current.testGetValue(), + "retro2004", + "current records the active custom icon id" + ); + + resetMocks(); + await CustomIconManager.ensureAppliedOrRevert(); + Assert.equal( + Glean.customIcon.current.testGetValue(), + "default", + "current records the default id when no custom icon is set" + ); + } +); + +/** + * This test verifies that ensureAppliedOrRevert() with a pref naming a known + * catalog id re-applies the runtime icon only, without rewriting shortcuts, + * and keeps the pref. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_applies_known_id() { + resetMocks(); + Services.prefs.setStringPref(PREF_ICON_ID, "retro2004"); + + await CustomIconManager.ensureAppliedOrRevert(); + + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(RETRO_RESOURCE_ID), + "runtime icon applied for a known id" + ); + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "ensureAppliedOrRevert does not rewrite shortcuts for a known id" + ); + Assert.equal( + Services.prefs.getStringPref(PREF_ICON_ID, ""), + "retro2004", + "pref retained" + ); + } +); + +/** + * This test verifies that ensureAppliedOrRevert() with a pref naming an id + * absent from the catalog (e.g. a newer build's icon, or one since retired) + * reverts the shortcuts and runtime icon to default and clears the pref. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_reverts_unknown_id() { + resetMocks(); + Services.prefs.setStringPref(PREF_ICON_ID, "icon-from-a-newer-build"); + + await CustomIconManager.ensureAppliedOrRevert(); + + // Unknown id -> revert: shortcuts reset to default, runtime cleared, pref + // cleared. + Assert.ok( + shellServiceMock.setShortcutsIcon.calledOnce, + "revert rewrote shortcuts" + ); + Assert.equal( + shellServiceMock.setShortcutsIcon.getCall(0).args[2], + 0, + "shortcuts reset to the default icon" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(0), + "runtime icon cleared" + ); + Assert.ok(!Services.prefs.prefHasUserValue(PREF_ICON_ID), "pref cleared"); + Assert.equal( + Glean.customIcon.changed.testGetValue(), + undefined, + "the startup reconcile of an unknown id is not a user change" + ); + } +); + +/** + * This test verifies that ensureAppliedOrRevert() does nothing when no custom + * icon pref is set. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_noop_without_pref() { + resetMocks(); + + await CustomIconManager.ensureAppliedOrRevert(); + + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "no shortcut work when no custom icon is recorded" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.notCalled, + "no runtime work when no custom icon is recorded" + ); + } +); + +/** + * This test checks that ensureAppliedOrRevert() will run setShortcutsIcon + * even if no custom ID is set, but only if it's being called because a remote + * profile updated. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_when_remoteProfileUpdated() { + resetMocks(); + + await CustomIconManager.ensureAppliedOrRevert( + true /* remoteProfileUpdated */ + ); + + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "Shortcuts were not modified if a remote profile cleared the icon" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnce, + "Runtime icon was modified if a remote profile cleared the icon" + ); + } +); + +/** + * This test verifies that the startup reconcile (remoteProfileUpdated = false) + * awaits SelectableProfileService.init() before reading the pref, so a custom + * icon synced late from the selectable-profiles database is still applied + * rather than missed. init() stands in for that shared-pref load and only sets + * the pref after yielding, so a reconcile that read the pref without awaiting + * would see no icon and apply nothing. + */ +add_task( + skipOnMsix(), + async function test_ensureAppliedOrRevert_waits_for_shared_pref_load() { + resetMocks(); + + spsInitStub.callsFake(async () => { + await Promise.resolve(); + Services.prefs.setStringPref(PREF_ICON_ID, "retro2004"); + }); + + await CustomIconManager.ensureAppliedOrRevert(); + + Assert.ok( + spsInitStub.calledOnce, + "The startup reconcile awaited SelectableProfileService.init()." + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledOnceWithExactly(RETRO_RESOURCE_ID), + "The icon synced during init() was applied to runtime windows." + ); + } +); + +/** + * This test verifies the theme-aware catalog shape: a theme-aware icon exposes + * distinct dark/light variants and resolveResourceId()/resolvePreview() pick the + * scheme-specific asset, while a flat icon ignores the scheme. + */ +add_task(async function test_theme_aware_catalog() { + let minimal = ICON_CATALOG.minimal; + Assert.ok(minimal.variants, "minimal is theme-aware"); + Assert.notEqual( + minimal.variants.dark.iconResourceId, + minimal.variants.light.iconResourceId, + "dark and light variants use distinct resource IDs" + ); + Assert.notEqual( + resolvePreview(minimal, "dark"), + resolvePreview(minimal, "light"), + "resolvePreview returns the scheme-specific preview for a theme-aware icon" + ); + Assert.equal( + resolveResourceId(minimal, "dark"), + minimal.variants.dark.iconResourceId, + "resolveResourceId picks the dark variant's id under a dark scheme" + ); + Assert.equal( + resolveResourceId(minimal, "light"), + minimal.variants.light.iconResourceId, + "resolveResourceId picks the light variant's id under a light scheme" + ); + + let retro = ICON_CATALOG.retro2004; + Assert.ok(!retro.variants, "retro2004 is theme-agnostic"); + Assert.equal( + resolvePreview(retro, "dark"), + resolvePreview(retro, "light"), + "a flat entry returns the same preview regardless of scheme" + ); + Assert.equal( + resolveResourceId(retro, "dark"), + retro.iconResourceId, + "a flat entry returns its single resource id regardless of scheme" + ); +}); + +/** + * This test verifies the OS-theme runtime behaviour of a theme-aware icon: + * apply() picks the variant matching the OS taskbar theme, and a + * look-and-feel-changed notification re-applies the other variant when (and + * only when) the OS theme actually flips. + * + * osColorScheme() reads the Windows registry, so we mock nsIWindowsRegKey for + * the duration of this task only (to avoid disturbing other registry reads) and + * drive SystemUsesLightTheme directly. + */ +add_task(skipOnMsix(), async function test_theme_change_reapplies_variant() { + resetMocks(); + + // The test flips this between OS_LIGHT/OS_DARK to simulate the user + // changing their OS theme. + let osTheme = OS_LIGHT; + let regKeyMock = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWindowsRegKey]), + open() {}, + close() {}, + hasValue: name => name === "SystemUsesLightTheme", + readIntValue: name => (name === "SystemUsesLightTheme" ? osTheme : 0), + }; + let regCid = MockRegistrar.register( + "@mozilla.org/windows-registry-key;1", + regKeyMock + ); + + let { dark, light } = ICON_CATALOG.minimal.variants; + + try { + // Startup registers the look-and-feel-changed observer. + CustomIconManager.applyRuntimeOverrideForStartup(); + + // Light OS theme -> Minimal applies the light variant. + osTheme = OS_LIGHT; + await CustomIconManager.apply("minimal"); + Assert.equal( + shellServiceMock.setShortcutsIcon.lastCall.args[2], + light.iconResourceId, + "Minimal applies the light variant under a light OS theme" + ); + Assert.ok( + winTaskbarMock.setAllWindowIcons.calledWith(light.iconResourceId), + "runtime window icon set to the light variant" + ); + + // Flip the OS to dark and notify -> the active Minimal re-applies as dark. + osTheme = OS_DARK; + shellServiceMock.setShortcutsIcon.resetHistory(); + winTaskbarMock.setAllWindowIcons.resetHistory(); + Services.obs.notifyObservers(null, "look-and-feel-changed"); + await TestUtils.waitForCondition( + () => shellServiceMock.setShortcutsIcon.called, + "icon re-applied after the OS theme flipped to dark" + ); + Assert.equal( + shellServiceMock.setShortcutsIcon.lastCall.args[2], + dark.iconResourceId, + "the dark variant is applied after the theme flips to dark" + ); + + // A notification with no actual theme change is a no-op (the + // gLastAppliedScheme guard short-circuits before re-applying). + shellServiceMock.setShortcutsIcon.resetHistory(); + Services.obs.notifyObservers(null, "look-and-feel-changed"); + Assert.ok( + shellServiceMock.setShortcutsIcon.notCalled, + "no re-apply when the OS theme is unchanged" + ); + } finally { + MockRegistrar.unregister(regCid); + Services.prefs.clearUserPref(PREF_ICON_ID); + } +}); + +/** + * This test verifies that ensureShortcutInPerUserStartMenu() does not create a + * shortcut when one already exists in the per-user Start Menu Programs folder. + */ +add_task( + skipOnMsix(), + async function test_ensureShortcutInPerUserStartMenu_already_exists() { + resetMocks(); + + let programsPath = Services.dirsvc.get("Progs", Ci.nsIFile).path; + shellServiceMock.enumerateInstallShortcuts.resolves([ + programsPath + "\\Nightly.lnk", + ]); + + await CustomIconManager.ensureShortcutInPerUserStartMenu(); + + Assert.ok( + shellServiceMock.createShortcut.notCalled, + "createShortcut not called when a per-user Start Menu shortcut already exists" + ); + } +); + +/** + * This test verifies that ensureShortcutInPerUserStartMenu() creates a shortcut + * in the Programs folder when none is found among the enumerated shortcuts. + */ +add_task( + skipOnMsix(), + async function test_ensureShortcutInPerUserStartMenu_creates_shortcut() { + resetMocks(); + // TEST_SHORTCUTS ("C:\\fake\\Desktop\\Nightly.lnk") does not live in + // the Programs dir, so the method must create the missing shortcut. + + await CustomIconManager.ensureShortcutInPerUserStartMenu(); + + Assert.ok( + shellServiceMock.createShortcut.calledOnce, + "createShortcut called when no per-user Start Menu shortcut exists" + ); + let [exeFile, args, , iconFile, iconIndex, aumid, location, name] = + shellServiceMock.createShortcut.getCall(0).args; + Assert.equal( + exeFile.path, + exePath(), + "shortcut targets the running executable" + ); + Assert.deepEqual(args, [], "no extra arguments"); + Assert.equal(iconFile.path, exePath(), "icon source is the executable"); + Assert.equal( + iconIndex, + 0, + "icon index 0 selects the executable's default icon" + ); + Assert.equal(aumid, TEST_AUMID, "shortcut carries the install AUMID"); + Assert.equal( + location, + "Programs", + "shortcut placed in the Programs location" + ); + Assert.ok(name.endsWith(".lnk"), "shortcut filename ends with .lnk"); + } +); + +/** + * This test verifies that when enumerateInstallShortcuts rejects, + * ensureShortcutInPerUserStartMenu() swallows the error and does not attempt + * to create a shortcut. + */ +add_task( + skipOnMsix(), + async function test_ensureShortcutInPerUserStartMenu_enumeration_failure() { + resetMocks(); + shellServiceMock.enumerateInstallShortcuts.rejects( + Components.Exception("mock enum failure", Cr.NS_ERROR_FAILURE) + ); + + await CustomIconManager.ensureShortcutInPerUserStartMenu(); + + Assert.ok( + shellServiceMock.createShortcut.notCalled, + "createShortcut not attempted when enumeration fails" + ); + } +); + +/** + * This test verifies that when createShortcut rejects, + * ensureShortcutInPerUserStartMenu() swallows the error and does not throw. + */ +add_task( + skipOnMsix(), + async function test_ensureShortcutInPerUserStartMenu_create_failure() { + resetMocks(); + shellServiceMock.createShortcut.rejects( + Components.Exception("mock create failure", Cr.NS_ERROR_FAILURE) + ); + + await CustomIconManager.ensureShortcutInPerUserStartMenu(); + + Assert.ok( + shellServiceMock.createShortcut.calledOnce, + "createShortcut was attempted despite the eventual failure" + ); + } +); + +/** + * This test verifies that ensureShortcutInPerUserStartMenu() is a no-op on + * MSIX (packaged) builds where shortcut creation is unsupported. + */ +add_task( + { skip_if: () => !ON_MSIX }, + async function test_ensureShortcutInPerUserStartMenu_noop_on_msix() { + resetMocks(); + + await CustomIconManager.ensureShortcutInPerUserStartMenu(); + + Assert.ok( + shellServiceMock.enumerateInstallShortcuts.notCalled, + "no enumeration on MSIX" + ); + Assert.ok( + shellServiceMock.createShortcut.notCalled, + "no shortcut creation on MSIX" + ); + } +); + +/** + * This test verifies that refreshTaskbarButtons() delegates to + * WinTaskbar.refreshTaskbarButtons(). + */ +add_task(function test_refreshTaskbarButtons_calls_wintaskbar() { + winTaskbarMock.refreshTaskbarButtons.reset(); + + CustomIconManager.refreshTaskbarButtons(); + + Assert.ok( + winTaskbarMock.refreshTaskbarButtons.calledOnce, + "refreshTaskbarButtons delegates to WinTaskbar" + ); +}); + +/** + * This test verifies that refreshTaskbarButtons() swallows errors thrown by + * WinTaskbar.refreshTaskbarButtons() rather than propagating them. + */ +add_task(function test_refreshTaskbarButtons_swallows_errors() { + winTaskbarMock.refreshTaskbarButtons.reset(); + winTaskbarMock.refreshTaskbarButtons.throws( + Components.Exception("mock failure", Cr.NS_ERROR_FAILURE) + ); + + CustomIconManager.refreshTaskbarButtons(); + + Assert.ok( + winTaskbarMock.refreshTaskbarButtons.calledOnce, + "refreshTaskbarButtons was attempted" + ); +}); diff --git a/src/zen/tests/mochitests/shell/unit/xpcshell.toml b/src/zen/tests/mochitests/shell/unit/xpcshell.toml index 39bb48115..106c8565a 100644 --- a/src/zen/tests/mochitests/shell/unit/xpcshell.toml +++ b/src/zen/tests/mochitests/shell/unit/xpcshell.toml @@ -5,6 +5,11 @@ run-if = [ firefox-appdir = "browser" tags = "os_integration" +["test_customIconManager.js"] +run-if = [ + "os == 'win'", +] + ["test_desktopEntryStatus.js"] run-if = [ "os == 'linux'", diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser.toml b/src/zen/tests/mochitests/tabMediaIndicator/browser.toml index 9eac10b02..a725df4df 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser.toml +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser.toml @@ -6,6 +6,8 @@ support-files = [ "audio.ogg", "audioEndedDuringPlaying.webm", "file_almostSilentAudioTrack.html", + "file_audiblechange_iframe.html", + "file_audiblechange_iframe_inner.html", "file_autoplay_media.html", "file_empty.html", "file_mediaPlayback.html", @@ -20,6 +22,8 @@ support-files = [ "silentAudioTrack.webm", ] +["browser_audiblechange_iframe.js"] + ["browser_destroy_iframe.js"] https_first_disabled = true @@ -33,6 +37,10 @@ https_first_disabled = true ["browser_mute2.js"] +["browser_mute_persist_navigation.js"] + +["browser_mute_restore_closed_audible_tab.js"] + ["browser_mute_webAudio.js"] ["browser_sound_indicator_silent_video.js"] diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_audiblechange_iframe.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_audiblechange_iframe.js new file mode 100644 index 000000000..474961241 --- /dev/null +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_audiblechange_iframe.js @@ -0,0 +1,134 @@ +// When a tab has audio sources in both the top-level document and an iframe, +// pausing only one of them must not make the tab sound indicator disappear — +// it should stay until all sources are silent. + +const PAGE_URL = GetTestWebBasedURL("file_audiblechange_iframe.html"); + +/** + * Start both top-level and iframe audio, wait for indicator, then pause each + * one in turn verifying the indicator only disappears when both are silent. + * + * @param {tab} tab + * @param {BrowsingContext} innerBC - iframe's browsing context + * @param {string} pauseFirst - "toplevel" or "iframe" + */ +async function testBothSourcesThenPauseOne(tab, innerBC, pauseFirst) { + const browser = tab.linkedBrowser; + + info(`play both sources (pause-first: ${pauseFirst})`); + await SpecialPowers.spawn(browser, [], async () => { + await content.document.getElementById("toplevel").play(); + }); + await SpecialPowers.spawn(innerBC, [], async () => { + await content.document.getElementById("audio").play(); + }); + await waitForTabSoundIndicatorAppears(tab); + + const observer = createSoundIndicatorObserver(tab); + + if (pauseFirst === "toplevel") { + info("pause top-level only — iframe still playing"); + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("toplevel").pause(); + }); + // Wait for the iframe audio to advance, proving it is still audible. + await SpecialPowers.spawn(innerBC, [], async () => { + await new Promise(r => { + content.document.getElementById("audio").ontimeupdate = r; + }); + }); + ok( + !observer.hasEverUpdated(), + "indicator stays after top-level pause (iframe still playing)" + ); + + info("pause iframe — all silent"); + await SpecialPowers.spawn(innerBC, [], () => { + content.document.getElementById("audio").pause(); + }); + } else { + info("pause iframe only — top-level still playing"); + await SpecialPowers.spawn(innerBC, [], () => { + content.document.getElementById("audio").pause(); + }); + // Wait for the top-level audio to advance, proving it is still audible. + await SpecialPowers.spawn(browser, [], async () => { + await new Promise(r => { + content.document.getElementById("toplevel").ontimeupdate = r; + }); + }); + ok( + !observer.hasEverUpdated(), + "indicator stays after iframe pause (top-level still playing)" + ); + + info("pause top-level — all silent"); + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("toplevel").pause(); + }); + } + + await waitForTabSoundIndicatorDisappears(tab); +} + +add_task(async function testSoundIndicatorPauseTopLevelFirst() { + info("open tab with top-level audio and same-origin iframe audio"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + const innerBC = tab.linkedBrowser.browsingContext.children[0]; + + await testBothSourcesThenPauseOne(tab, innerBC, "toplevel"); + + info("remove tab"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testSoundIndicatorPauseIframeFirst() { + info("open tab with top-level audio and same-origin iframe audio"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + const innerBC = tab.linkedBrowser.browsingContext.children[0]; + + await testBothSourcesThenPauseOne(tab, innerBC, "iframe"); + + info("remove tab"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testSoundIndicatorOnlyTopLevelPlaying() { + info("open tab and play only the top-level audio (iframe silent)"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + const browser = tab.linkedBrowser; + + await SpecialPowers.spawn(browser, [], async () => { + await content.document.getElementById("toplevel").play(); + }); + await waitForTabSoundIndicatorAppears(tab); + + info("pause top-level — tab should go silent"); + await SpecialPowers.spawn(browser, [], () => { + content.document.getElementById("toplevel").pause(); + }); + await waitForTabSoundIndicatorDisappears(tab); + + info("remove tab"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testSoundIndicatorOnlyIframePlaying() { + info("open tab and play only the iframe audio (top-level silent)"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + const innerBC = tab.linkedBrowser.browsingContext.children[0]; + + await SpecialPowers.spawn(innerBC, [], async () => { + await content.document.getElementById("audio").play(); + }); + await waitForTabSoundIndicatorAppears(tab); + + info("pause iframe — tab should go silent"); + await SpecialPowers.spawn(innerBC, [], () => { + content.document.getElementById("audio").pause(); + }); + await waitForTabSoundIndicatorDisappears(tab); + + info("remove tab"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_destroy_iframe.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_destroy_iframe.js index f977d1d66..b89f19e3e 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser_destroy_iframe.js +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_destroy_iframe.js @@ -4,6 +4,7 @@ const CORS_AUTPLAY_PAGE_URL = GetTestWebBasedURL( "file_autoplay_media.html", true ); +const CORS_WEB_AUDIO_PAGE_URL = GetTestWebBasedURL("file_webAudio.html", true); /** * When an iframe that has audible media gets destroyed, if there is no other @@ -32,6 +33,33 @@ add_task(async function testDestroyAudibleIframe() { } }); +/** + * When an iframe with a Web Audio context (uncontrolled source) is destroyed, + * the sound indicator should disappear if no other audible source remains. + * This exercises the mAudibleUncontrolledSources.Remove() path in + * MediaStatusManager::NotifyBrowsingContextDiscarded(). + */ +add_task(async function testDestroyAudibleWebAudioIframe() { + info(`open a tab, create a cross-origin iframe with Web Audio`); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EMPTY_PAGE_URL + ); + await createIframeAndLoadURL(tab, CORS_WEB_AUDIO_PAGE_URL); + + info(`sound indicator should appear because of audible Web Audio`); + await waitForTabSoundIndicatorAppears(tab); + + info( + `sound indicator should disappear after destroying the Web Audio iframe` + ); + await removeIframe(tab); + await waitForTabSoundIndicatorDisappears(tab); + + info("remove tab"); + BrowserTestUtils.removeTab(tab); +}); + function createIframeAndLoadURL(tab, url) { // eslint-disable-next-line no-shadow return SpecialPowers.spawn(tab.linkedBrowser, [url], async url => { diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback.js index 89143bf83..ed6a85729 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback.js +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback.js @@ -1,24 +1,13 @@ const PAGE = GetTestWebBasedURL("file_mediaPlayback.html"); const FRAME = GetTestWebBasedURL("file_mediaPlaybackFrame.html"); -function wait_for_event(browser, event) { - return BrowserTestUtils.waitForEvent(browser, event, false, e => { - is( - e.originalTarget, - browser, - "Event must be dispatched to correct browser." - ); - ok(!e.cancelable, "The event should not be cancelable"); - return true; - }); -} - async function test_on_browser(url, browser) { info(`run test for ${url}`); - const startPromise = wait_for_event(browser, "DOMAudioPlaybackStarted"); + const tab = gBrowser.getTabForBrowser(browser); + const startPromise = waitForTabSoundIndicatorAppears(tab); BrowserTestUtils.startLoadingURIString(browser, url); await startPromise; - await wait_for_event(browser, "DOMAudioPlaybackStopped"); + await waitForTabSoundIndicatorDisappears(tab); } add_task(async function test_page() { diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback_mute.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback_mute.js index fb771c23a..ee425b3b5 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback_mute.js +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mediaPlayback_mute.js @@ -1,17 +1,6 @@ const PAGE = GetTestWebBasedURL("file_mediaPlayback2.html"); const FRAME = GetTestWebBasedURL("file_mediaPlaybackFrame2.html"); -function wait_for_event(browser, event) { - return BrowserTestUtils.waitForEvent(browser, event, false, e => { - is( - e.originalTarget, - browser, - "Event must be dispatched to correct browser." - ); - return true; - }); -} - function test_audio_in_browser() { function get_audio_element() { var doc = content.document; @@ -36,18 +25,21 @@ function test_audio_in_browser() { } async function test_on_browser(url, browser) { + const tab = gBrowser.getTabForBrowser(browser); BrowserTestUtils.startLoadingURIString(browser, url); - await wait_for_event(browser, "DOMAudioPlaybackStarted"); + await waitForTabSoundIndicatorAppears(tab); var result = await SpecialPowers.spawn(browser, [], test_audio_in_browser); is(result.computedVolume, 1, "Audio volume is 1"); is(result.computedMuted, false, "Audio is not muted"); ok(!browser.audioMuted, "Audio should not be muted by default"); - browser.mute(); + // Use the tab-level API (which internally calls mediaController.mute()) to + // test the full browser-mute path as the user would trigger it. + tab.toggleMuteAudio(); ok(browser.audioMuted, "Audio should be muted now"); - await wait_for_event(browser, "DOMAudioPlaybackStopped"); + await waitForTabSoundIndicatorDisappears(tab); result = await SpecialPowers.spawn(browser, [], test_audio_in_browser); is(result.computedVolume, 0, "Audio volume is 0 when muted"); @@ -55,8 +47,9 @@ async function test_on_browser(url, browser) { } async function test_visibility(url, browser) { + const tab = gBrowser.getTabForBrowser(browser); BrowserTestUtils.startLoadingURIString(browser, url); - await wait_for_event(browser, "DOMAudioPlaybackStarted"); + await waitForTabSoundIndicatorAppears(tab); var result = await SpecialPowers.spawn(browser, [], test_audio_in_browser); is(result.computedVolume, 1, "Audio volume is 1"); @@ -71,10 +64,12 @@ async function test_visibility(url, browser) { ); ok(!browser.audioMuted, "Audio should not be muted by default"); - browser.mute(); + // Use the tab-level API (which internally calls mediaController.mute()) to + // test the full browser-mute path as the user would trigger it. + tab.toggleMuteAudio(); ok(browser.audioMuted, "Audio should be muted now"); - await wait_for_event(browser, "DOMAudioPlaybackStopped"); + await waitForTabSoundIndicatorDisappears(tab); result = await SpecialPowers.spawn(browser, [], test_audio_in_browser); is(result.computedVolume, 0, "Audio volume is 0 when muted"); diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute.js index 826e06c3d..890fd7bd3 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute.js +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute.js @@ -2,9 +2,9 @@ const PAGE = "data:text/html,page"; function test_on_browser(browser) { ok(!browser.audioMuted, "Audio should not be muted by default"); - browser.mute(); + browser.browsingContext.mediaController.mute(); ok(browser.audioMuted, "Audio should be muted now"); - browser.unmute(); + browser.browsingContext.mediaController.unmute(); ok(!browser.audioMuted, "Audio should be unmuted now"); } diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute2.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute2.js index 5e845454d..948e351e9 100644 --- a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute2.js +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute2.js @@ -2,7 +2,7 @@ const PAGE = "data:text/html,page"; async function test_on_browser(browser) { ok(!browser.audioMuted, "Audio should not be muted by default"); - browser.mute(); + browser.browsingContext.mediaController.mute(); ok(browser.audioMuted, "Audio should be muted now"); await BrowserTestUtils.withNewTab( @@ -13,7 +13,7 @@ async function test_on_browser(browser) { test_on_browser2 ); - browser.unmute(); + browser.browsingContext.mediaController.unmute(); ok(!browser.audioMuted, "Audio should be unmuted now"); } diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_persist_navigation.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_persist_navigation.js new file mode 100644 index 000000000..e5c3aa047 --- /dev/null +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_persist_navigation.js @@ -0,0 +1,86 @@ +// Verify that tab mute state persists across navigation: a tab muted before +// navigation keeps audio muted on the new page, and unmuting restores audibility. + +"use strict"; + +const PAGE = GetTestWebBasedURL("file_mediaPlayback2.html"); + +// Mute a tab while it is on about:blank, then navigate to a page with audio. +// Verify that the audio in the new page is muted immediately on playback, and +// that unmuting the tab makes the audio audible in content. +add_task(async function test_mute_persists_and_unmute_restores() { + await SpecialPowers.pushPrefEnv({ + set: [["media.useAudioChannelService.testing", true]], + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + const browser = tab.linkedBrowser; + + ok(!browser.audioMuted, "Tab should not be muted initially"); + tab.toggleMuteAudio(); + ok(browser.audioMuted, "Tab should now be muted"); + ok(tab.hasAttribute("muted"), "Tab element should have muted attribute"); + + BrowserTestUtils.startLoadingURIString(browser, PAGE); + await BrowserTestUtils.browserLoaded(browser, false, PAGE); + + await waitForAudioPlaying(browser); + + let computedMuted = await getAudioComputedMuted(browser); + is( + computedMuted, + true, + "Audio should be muted in the content page after navigation" + ); + ok( + browser.audioMuted, + "browser.audioMuted should remain true after navigation" + ); + ok( + !tab.hasAttribute("soundplaying"), + "Tab should not show the sound indicator while muted" + ); + + tab.toggleMuteAudio(); + ok(!browser.audioMuted, "Tab should be unmuted now"); + + await TestUtils.waitForCondition(async () => { + return !(await getAudioComputedMuted(browser)); + }, "Audio should become unmuted in content after tab unmute"); + + computedMuted = await getAudioComputedMuted(browser); + is(computedMuted, false, "Audio should no longer be muted after unmute"); + + BrowserTestUtils.removeTab(tab); +}); + +// Following are helper functions. +async function getAudioComputedMuted(browser) { + return SpecialPowers.spawn(browser, [], () => { + const audio = content.document.getElementById("v"); + if (!audio) { + ok(false, "audio element not found"); + return null; + } + return audio.computedMuted; + }); +} + +async function waitForAudioPlaying(browser) { + await SpecialPowers.spawn(browser, [], async () => { + const audio = content.document.getElementById("v"); + if (!audio) { + ok(false, "audio element not found"); + return; + } + if (!audio.paused) { + return; + } + await new Promise(resolve => + audio.addEventListener("playing", resolve, { once: true }) + ); + }); +} diff --git a/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_restore_closed_audible_tab.js b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_restore_closed_audible_tab.js new file mode 100644 index 000000000..7905bc3a4 --- /dev/null +++ b/src/zen/tests/mochitests/tabMediaIndicator/browser_mute_restore_closed_audible_tab.js @@ -0,0 +1,93 @@ +// Verify that closing a tab preserves the mute state it gets restored with: a +// tab closed while it was audible and unmuted comes back unmuted and audible, +// and a tab the user muted comes back muted and silent. + +"use strict"; + +const PAGE = GetTestWebBasedURL("file_mediaPlayback2.html"); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["media.useAudioChannelService.testing", true]], + }); +}); + +add_task(async function test_closing_audible_tab_restores_unmuted() { + const tab = await openAudibleTab(); + ok(!tab.muted, "Tab is not muted before it gets closed"); + ok(tab.soundPlaying, "Tab is still audible when it gets closed"); + + const restored = await closeAndRestoreTab(tab); + ok(!restored.muted, "Restored tab should not be muted"); + ok( + !restored.linkedBrowser.audioMuted, + "Restored tab's audio should not be muted" + ); + await waitForTabSoundIndicatorAppears(restored); + + BrowserTestUtils.removeTab(restored); +}); + +add_task(async function test_closing_muted_tab_restores_muted() { + const tab = await openAudibleTab(); + tab.toggleMuteAudio(); + ok(tab.muted, "Tab is muted before it gets closed"); + await waitForTabSoundIndicatorDisappears(tab); + + const restored = await closeAndRestoreTab(tab); + ok(restored.muted, "Restored tab should still be muted"); + ok(restored.linkedBrowser.audioMuted, "Restored tab's audio should be muted"); + + await BrowserTestUtils.browserLoaded(restored.linkedBrowser, false, PAGE); + is( + await getComputedMutedOncePlaying(restored.linkedBrowser), + true, + "Restored tab stays silent once its media resumes" + ); + + BrowserTestUtils.removeTab(restored); +}); + +// Following are helper functions. +async function openAudibleTab() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + await waitForTabSoundIndicatorAppears(tab); + return tab; +} + +async function closeAndRestoreTab(tab) { + const closedTabCount = SessionStore.getClosedTabCountForWindow(window); + const mutedWhenClosed = tab.muted; + + const flushed = BrowserTestUtils.waitForSessionStoreUpdate(tab); + await BrowserTestUtils.removeTab(tab); + await flushed; + + is( + SessionStore.getClosedTabCountForWindow(window), + closedTabCount + 1, + "SessionStore recorded the closed tab" + ); + is( + !!SessionStore.getClosedTabData(window)[0].state.muted, + mutedWhenClosed, + "Closed tab data records the mute state the tab was closed with" + ); + + return SessionStore.undoCloseTab(window, 0); +} + +async function getComputedMutedOncePlaying(browser) { + return SpecialPowers.spawn(browser, [], async () => { + const audio = await ContentTaskUtils.waitForCondition( + () => content.document.getElementById("v"), + "wait for the restored page to create its audio element" + ); + if (audio.paused) { + await new Promise(resolve => + audio.addEventListener("playing", resolve, { once: true }) + ); + } + return audio.computedMuted; + }); +} diff --git a/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe.html b/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe.html new file mode 100644 index 000000000..2d1b07c46 --- /dev/null +++ b/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe.html @@ -0,0 +1,3 @@ + + + diff --git a/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe_inner.html b/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe_inner.html new file mode 100644 index 000000000..dbb61b073 --- /dev/null +++ b/src/zen/tests/mochitests/tabMediaIndicator/file_audiblechange_iframe_inner.html @@ -0,0 +1,2 @@ + + diff --git a/surfer.json b/surfer.json index ca6fb51aa..9e0f4361b 100644 --- a/surfer.json +++ b/surfer.json @@ -5,7 +5,7 @@ "binaryName": "zen", "version": { "product": "firefox", - "version": "153.0.4", + "version": "154.0", "candidate": "154.0", "candidateBuild": 1 }, @@ -20,7 +20,7 @@ "brandShortName": "Zen", "brandFullName": "Zen Browser", "release": { - "displayVersion": "1.21.14b", + "displayVersion": "1.21.15b", "github": { "repo": "zen-browser/desktop" },