Update On Sun Apr 13 20:22:56 CEST 2025

This commit is contained in:
github-action[bot] 2025-04-13 20:22:57 +02:00
parent 745952e3b8
commit 9c69112ad3
2090 changed files with 102264 additions and 32761 deletions

33
Cargo.lock generated
View file

@ -2524,6 +2524,7 @@ dependencies = [
name = "gkrust-uniffi-components" name = "gkrust-uniffi-components"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hashbrown 0.15.2",
"relevancy", "relevancy",
"search", "search",
"suggest", "suggest",
@ -2794,7 +2795,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1" checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1"
dependencies = [ dependencies = [
"arbitrary",
"cfg-if", "cfg-if",
"crunchy", "crunchy",
"num-traits", "num-traits",
@ -4498,7 +4498,7 @@ checksum = "a2983372caf4480544083767bf2d27defafe32af49ab4df3a0b7fc90793a3664"
[[package]] [[package]]
name = "naga" name = "naga"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
@ -4506,7 +4506,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"codespan-reporting", "codespan-reporting",
"half 2.5.0", "half 2.5.0",
"hashbrown 0.14.999", "hashbrown 0.15.2",
"hexf-parse", "hexf-parse",
"indexmap", "indexmap",
"log", "log",
@ -5931,19 +5931,20 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.0.0" version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513" checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [ dependencies = [
"serde", "serde",
"serde_derive",
"serde_with_macros", "serde_with_macros",
] ]
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.0.0" version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc7d5d3932fb12ce722ee5e64dd38c504efba37567f0c402f6ca728c3b8b070" checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
@ -7266,7 +7267,7 @@ version = "0.3.100"
[[package]] [[package]]
name = "webdriver" name = "webdriver"
version = "0.52.1" version = "0.53.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@ -7427,7 +7428,7 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-core" name = "wgpu-core"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
@ -7436,7 +7437,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"hashbrown 0.14.999", "hashbrown 0.15.2",
"indexmap", "indexmap",
"log", "log",
"naga", "naga",
@ -7457,7 +7458,7 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-core-deps-apple" name = "wgpu-core-deps-apple"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
@ -7465,7 +7466,7 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-core-deps-windows-linux-android" name = "wgpu-core-deps-windows-linux-android"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"wgpu-hal", "wgpu-hal",
] ]
@ -7473,7 +7474,7 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-hal" name = "wgpu-hal"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"arrayvec", "arrayvec",
@ -7487,7 +7488,7 @@ dependencies = [
"gpu-alloc", "gpu-alloc",
"gpu-allocator", "gpu-allocator",
"gpu-descriptor", "gpu-descriptor",
"hashbrown 0.14.999", "hashbrown 0.15.2",
"libc", "libc",
"libloading", "libloading",
"log", "log",
@ -7509,7 +7510,7 @@ dependencies = [
[[package]] [[package]]
name = "wgpu-types" name = "wgpu-types"
version = "24.0.0" version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5" source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"bytemuck", "bytemuck",
@ -7723,7 +7724,7 @@ dependencies = [
[[package]] [[package]]
name = "wr_malloc_size_of" name = "wr_malloc_size_of"
version = "0.0.2" version = "0.2.0"
dependencies = [ dependencies = [
"app_units", "app_units",
"euclid", "euclid",

View file

@ -554,6 +554,7 @@ void DocManager::ClearDocCache() {
} }
void DocManager::RemoteDocAdded(DocAccessibleParent* aDoc) { void DocManager::RemoteDocAdded(DocAccessibleParent* aDoc) {
MOZ_ASSERT(aDoc->IsTopLevel());
if (!sRemoteDocuments) { if (!sRemoteDocuments) {
sRemoteDocuments = new nsTArray<DocAccessibleParent*>; sRemoteDocuments = new nsTArray<DocAccessibleParent*>;
ClearOnShutdown(&sRemoteDocuments); ClearOnShutdown(&sRemoteDocuments);
@ -563,6 +564,12 @@ void DocManager::RemoteDocAdded(DocAccessibleParent* aDoc) {
"How did we already have the doc!"); "How did we already have the doc!");
sRemoteDocuments->AppendElement(aDoc); sRemoteDocuments->AppendElement(aDoc);
ProxyCreated(aDoc); ProxyCreated(aDoc);
// Fire a reorder event on the OuterDocAccessible.
if (LocalAccessible* outerDoc = aDoc->OuterDocOfRemoteBrowser()) {
MOZ_ASSERT(outerDoc->Document());
RefPtr<AccReorderEvent> reorder = new AccReorderEvent(outerDoc);
outerDoc->Document()->FireDelayedEvent(reorder);
}
} }
DocAccessible* mozilla::a11y::GetExistingDocAccessible( DocAccessible* mozilla::a11y::GetExistingDocAccessible(

View file

@ -15,7 +15,6 @@ XULMAP_TYPE(menu, XULMenuitemAccessible)
XULMAP_TYPE(menubar, XULMenubarAccessible) XULMAP_TYPE(menubar, XULMenubarAccessible)
XULMAP_TYPE(menucaption, XULMenuitemAccessible) XULMAP_TYPE(menucaption, XULMenuitemAccessible)
XULMAP_TYPE(menuitem, XULMenuitemAccessible) XULMAP_TYPE(menuitem, XULMenuitemAccessible)
XULMAP_TYPE(menulist, XULComboboxAccessible)
XULMAP_TYPE(menuseparator, XULMenuSeparatorAccessible) XULMAP_TYPE(menuseparator, XULMenuSeparatorAccessible)
XULMAP_TYPE(notification, XULAlertAccessible) XULMAP_TYPE(notification, XULAlertAccessible)
XULMAP_TYPE(radio, XULRadioButtonAccessible) XULMAP_TYPE(radio, XULRadioButtonAccessible)
@ -67,6 +66,19 @@ XULMAP(image,
return new ImageAccessible(aElement, aContext->Document()); return new ImageAccessible(aElement, aContext->Document());
}) })
XULMAP(menulist,
[](Element* aElement, LocalAccessible* aContext) -> LocalAccessible* {
nsAutoString domID;
if (nsCoreUtils::GetID(aElement, domID)) {
if (domID.Equals(u"ContentSelectDropdown"_ns)) {
return new XULContentSelectDropdownAccessible(
aElement, aContext->Document());
}
}
return new XULComboboxAccessible(aElement, aContext->Document());
})
XULMAP(menupopup, [](Element* aElement, LocalAccessible* aContext) { XULMAP(menupopup, [](Element* aElement, LocalAccessible* aContext) {
return CreateMenupopupAccessible(aElement, aContext); return CreateMenupopupAccessible(aElement, aContext);
}) })

View file

@ -1806,11 +1806,8 @@ void DocAccessible::ProcessLoad() {
#endif #endif
// Do not fire document complete/stop events for root chrome document // Do not fire document complete/stop events for root chrome document
// accessibles and for frame/iframe documents because // accessibles because screen readers start working on focus event in the case
// a) screen readers start working on focus event in the case of root chrome // of root chrome documents.
// documents
// b) document load event on sub documents causes screen readers to act is if
// entire page is reloaded.
if (!IsLoadEventTarget()) return; if (!IsLoadEventTarget()) return;
// Fire complete/load stopped if the load event type is given. // Fire complete/load stopped if the load event type is given.
@ -2984,31 +2981,7 @@ void DocAccessible::ShutdownChildrenInSubtree(LocalAccessible* aAccessible) {
} }
bool DocAccessible::IsLoadEventTarget() const { bool DocAccessible::IsLoadEventTarget() const {
nsCOMPtr<nsIDocShellTreeItem> treeItem = mDocumentNode->GetDocShell(); return mDocumentNode->GetBrowsingContext()->IsContent();
if (!treeItem) {
return false;
}
nsCOMPtr<nsIDocShellTreeItem> parentTreeItem;
treeItem->GetInProcessParent(getter_AddRefs(parentTreeItem));
// Not a root document.
if (parentTreeItem) {
// Return true if it's either:
// a) tab document;
nsCOMPtr<nsIDocShellTreeItem> rootTreeItem;
treeItem->GetInProcessRootTreeItem(getter_AddRefs(rootTreeItem));
if (parentTreeItem == rootTreeItem) return true;
// b) frame/iframe document and its parent document is not in loading state
// Note: we can get notifications while document is loading (and thus
// while there's no parent document yet).
DocAccessible* parentDoc = ParentDocument();
return parentDoc && parentDoc->HasLoadState(eCompletelyLoaded);
}
// It's content (not chrome) root document.
return (treeItem->ItemType() == nsIDocShellTreeItem::typeContent);
} }
void DocAccessible::SetIPCDoc(DocAccessibleChild* aIPCDoc) { void DocAccessible::SetIPCDoc(DocAccessibleChild* aIPCDoc) {

View file

@ -624,10 +624,8 @@ class DocAccessible : public HyperTextAccessible,
* Return true if the document is a target of document loading events * Return true if the document is a target of document loading events
* (for example, state busy change or document reload events). * (for example, state busy change or document reload events).
* *
* Rules: The root chrome document accessible is never an event target * Rule: The root chrome document accessible is never an event target
* (for example, Firefox UI window). If the sub document is loaded within its * (for example, Firefox UI window).
* parent document then the parent document is a target only (aka events
* coalescence).
*/ */
bool IsLoadEventTarget() const; bool IsLoadEventTarget() const;

View file

@ -126,36 +126,18 @@ export const CommonUtils = {
}, },
/** /**
* Extract DOMNode id from an accessible. If the accessible is in the remote * Obtain DOMNode id from an accessible. This simply queries the .id property
* process, DOMNode is not present in parent process. However, if specified by * on the accessible, but it catches exceptions which might occur if the
* the author, DOMNode id will be attached to an accessible object. * accessible has died.
*
* @param {nsIAccessible} accessible accessible * @param {nsIAccessible} accessible accessible
* @return {String?} DOMNode id if available * @return {String?} DOMNode id if available
*/ */
getAccessibleDOMNodeID(accessible) { getAccessibleDOMNodeID(accessible) {
if (accessible instanceof Ci.nsIAccessibleDocument) {
// If accessible is a document, trying to find its document body id.
try {
return accessible.DOMNode.body.id;
} catch (e) {
/* This only works if accessible is not a proxy. */
}
}
try { try {
return accessible.DOMNode.id;
} catch (e) {
/* This will fail if DOMNode is in different process. */
}
try {
// When e10s is enabled, accessible will have an "id" property if its
// corresponding DOMNode has an id. If accessible is a document, its "id"
// property corresponds to the "id" of its body element.
return accessible.id; return accessible.id;
} catch (e) { } catch (e) {
/* This will fail if accessible is not a proxy. */ // This will fail if the accessible has died.
} }
return null; return null;
}, },

View file

@ -19,7 +19,6 @@ support-files = [
["browser_test_caret_move_granularity.js"] ["browser_test_caret_move_granularity.js"]
["browser_test_docload.js"] ["browser_test_docload.js"]
skip-if = ["true"]
["browser_test_focus_browserui.js"] ["browser_test_focus_browserui.js"]

View file

@ -31,17 +31,13 @@ function urlChecker(url) {
} }
async function runTests(browser) { async function runTests(browser) {
let onLoadEvents = waitForEvents({ let onLoadEvents = waitForEvents([
expected: [ [EVENT_REORDER, getAccessible(browser)],
[EVENT_REORDER, getAccessible(browser)], [EVENT_DOCUMENT_LOAD_COMPLETE, "body2"],
[EVENT_DOCUMENT_LOAD_COMPLETE, "body2"], [EVENT_STATE_CHANGE, busyChecker(false)],
[EVENT_STATE_CHANGE, busyChecker(false)], [EVENT_DOCUMENT_LOAD_COMPLETE, inIframeChecker("iframe1")],
], [EVENT_STATE_CHANGE, inIframeChecker("iframe1")],
unexpected: [ ]);
[EVENT_DOCUMENT_LOAD_COMPLETE, inIframeChecker("iframe1")],
[EVENT_STATE_CHANGE, inIframeChecker("iframe1")],
],
});
BrowserTestUtils.startLoadingURIString( BrowserTestUtils.startLoadingURIString(
browser, browser,
@ -123,6 +119,6 @@ async function runTests(browser) {
} }
/** /**
* Test caching of accessible object states * Test events when a document loads.
*/ */
addAccessibleTask("", runTests); addAccessibleTask("", runTests, { chrome: true, topLevel: true });

View file

@ -595,6 +595,9 @@ function accessibleTask(doc, task, options = {}) {
} else { } else {
({ accessible: docAccessible } = await onContentDocLoad); ({ accessible: docAccessible } = await onContentDocLoad);
} }
// The test may want to access document methods/attributes such as URL
// and browsingContext.
docAccessible.QueryInterface(nsIAccessibleDocument);
let iframeDocAccessible; let iframeDocAccessible;
if (gIsIframe) { if (gIsIframe) {
if (!options.skipFissionDocLoad) { if (!options.skipFissionDocLoad) {
@ -603,6 +606,7 @@ function accessibleTask(doc, task, options = {}) {
? (await onIframeDocLoad).accessible ? (await onIframeDocLoad).accessible
: findAccessibleChildByID(docAccessible, DEFAULT_IFRAME_ID) : findAccessibleChildByID(docAccessible, DEFAULT_IFRAME_ID)
.firstChild; .firstChild;
iframeDocAccessible.QueryInterface(nsIAccessibleDocument);
} }
} }

View file

@ -30,6 +30,8 @@ skip-if = [
["browser_searchbar.js"] ["browser_searchbar.js"]
["browser_select.js"]
["browser_shadowdom.js"] ["browser_shadowdom.js"]
["browser_test_nsIAccessibleDocument_URL.js"] ["browser_test_nsIAccessibleDocument_URL.js"]

View file

@ -0,0 +1,168 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/* import-globals-from ../../mochitest/states.js */
loadScripts({ name: "states.js", dir: MOCHITESTS_DIR });
/* import-globals-from ../../mochitest/role.js */
loadScripts({ name: "role.js", dir: MOCHITESTS_DIR });
/**
* Verify that the local accessible created in the parent process for the
* id=ContentSelectDropdown node has its parent spoofed to the relevant
* expanded select.
* Note: id=select2 is unused and serves only to ensure we don't return
* _just any_ select inside the document :)
*/
addAccessibleTask(
`
<select id="select">
<option id="a">optiona</option>
</select>
<select id="select2">
<option id="b">optionb</option>
</select>
`,
async function testSelectAncestorChain(browser, accDoc) {
const LOCAL_DROPDOWN_ID = "ContentSelectDropdown";
const rootAcc = getRootAccessible(document);
ok(rootAcc, "Root Accessible exists");
const optA = findAccessibleChildByID(accDoc, "a");
const select = findAccessibleChildByID(accDoc, "select");
let remoteAccDropdown = select.firstChild;
ok(remoteAccDropdown, "Remote acc dropdown exists");
let isRemote = true;
try {
remoteAccDropdown.id;
} catch (e) {
isRemote = false;
}
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Attempt to fetch the local dropdown
let localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
is(
localAccDropdown,
null,
"Local dropdown cannot be reached while select is collapsed"
);
// Focus the combo box.
await invokeFocus(browser, "select");
// Expand the combobox dropdown.
let p = waitForEvent(EVENT_STATE_CHANGE, LOCAL_DROPDOWN_ID);
EventUtils.synthesizeKey("VK_SPACE");
await p;
// Attempt to fetch the local dropdown
localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
ok(localAccDropdown, "Local dropdown exists when select is expanded.");
// Verify the dropdown rendered in the parent
// process is a child of the select
is(localAccDropdown.parent, select, "Dropdown is a child of the select");
// Verify walking from the select produces the
// appropriate option
remoteAccDropdown = select.firstChild;
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Close the dropdown.
p = waitForEvents({
expected: [[EVENT_HIDE, LOCAL_DROPDOWN_ID]],
});
EventUtils.synthesizeKey("VK_ESCAPE");
await p;
// Verify walking down from the select produces the
// appropriate option
remoteAccDropdown = select.firstChild;
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Attempt to fetch the local dropdown
localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
is(
localAccDropdown,
null,
"Local dropdown cannot be reached while select is collapsed"
);
},
{
chrome: true,
topLevel: true,
iframe: true,
remoteIframe: true,
}
);

View file

@ -868,33 +868,17 @@ function getTextFromClipboard() {
} }
/** /**
* Extract DOMNode id from an accessible. If e10s is enabled, DOMNode is not * Obtain DOMNode id from an accessible. This simply queries the .id property
* present in parent process but, if available, DOMNode id is attached to an * on the accessible, but it catches exceptions which might occur if the
* accessible object. * accessible has died.
* @param {nsIAccessible} accessible accessible * @param {nsIAccessible} accessible accessible
* @return {String?} DOMNode id if available * @return {String?} DOMNode id if available
*/ */
function getAccessibleDOMNodeID(accessible) { function getAccessibleDOMNodeID(accessible) {
if (accessible instanceof nsIAccessibleDocument) {
// If accessible is a document, trying to find its document body id.
try {
return accessible.DOMNode.body.id;
} catch (e) {
/* This only works if accessible is not a proxy. */
}
}
try { try {
return accessible.DOMNode.id;
} catch (e) {
/* This will fail if DOMNode is in different process. */
}
try {
// When e10s is enabled, accessible will have an "id" property if its
// corresponding DOMNode has an id. If accessible is a document, its "id"
// property corresponds to the "id" of its body element.
return accessible.id; return accessible.id;
} catch (e) { } catch (e) {
/* This will fail if accessible is not a proxy. */ // This will fail if the accessible has died.
} }
return null; return null;
} }

View file

@ -173,13 +173,8 @@ xpcAccessible::GetId(nsAString& aID) {
return NS_ERROR_FAILURE; return NS_ERROR_FAILURE;
} }
RemoteAccessible* proxy = IntlGeneric()->AsRemote();
if (!proxy) {
return NS_ERROR_FAILURE;
}
nsString id; nsString id;
proxy->DOMNodeID(id); IntlGeneric()->DOMNodeID(id);
aID.Assign(id); aID.Assign(id);
return NS_OK; return NS_OK;

View file

@ -9,6 +9,9 @@
#include "nsAccessibilityService.h" #include "nsAccessibilityService.h"
#include "DocAccessible.h" #include "DocAccessible.h"
#include "nsCoreUtils.h" #include "nsCoreUtils.h"
#include "nsFocusManager.h"
#include "mozilla/a11y/DocAccessibleParent.h"
#include "mozilla/a11y/Role.h" #include "mozilla/a11y/Role.h"
#include "States.h" #include "States.h"
@ -140,3 +143,46 @@ bool XULComboboxAccessible::AreItemsOperable() const {
return false; return false;
} }
////////////////////////////////////////////////////////////////////////////////
// XULContentSelectDropdownAccessible
////////////////////////////////////////////////////////////////////////////////
Accessible* XULContentSelectDropdownAccessible::Parent() const {
// We render the expanded dropdown for <select>s in the parent process
// as a child of the application accessible. This confuses some
// ATs which expect the select to _always_ parent the dropdown (in
// both expanded and collapsed states).
// To rectify this, we spoof the <select> as the parent of the
// expanded dropdown here. Note that we do not spoof the child relationship.
// First, try to find the select that spawned this dropdown.
// The select that was activated does not get states::EXPANDED, but
// it should still have focus.
Accessible* focusedAcc = nullptr;
if (auto* focusedNode = FocusMgr()->FocusedDOMNode()) {
// If we get a node here, we're in a non-remote browser.
DocAccessible* doc =
GetAccService()->GetDocAccessible(focusedNode->OwnerDoc());
focusedAcc = doc->GetAccessible(focusedNode);
} else {
nsFocusManager* focusManagerDOM = nsFocusManager::GetFocusManager();
dom::BrowsingContext* focusedContext =
focusManagerDOM->GetFocusedBrowsingContextInChrome();
DocAccessibleParent* focusedDoc =
DocAccessibleParent::GetFrom(focusedContext);
MOZ_ASSERT(focusedDoc && focusedDoc->IsDoc(), "No focused document found");
focusedAcc = focusedDoc->AsDoc()->GetFocusedAcc();
}
if (!NS_WARN_IF(focusedAcc && focusedAcc->IsHTMLCombobox())) {
// We can sometimes get a document here if the select that
// this dropdown should anchor to loses focus. This can happen when
// calling AXPressed on macOS. Call into the regular parent
// function instead.
return LocalParent();
}
return focusedAcc;
}

View file

@ -37,6 +37,21 @@ class XULComboboxAccessible : public AccessibleWrap {
MOZ_CAN_RUN_SCRIPT_BOUNDARY bool AreItemsOperable() const override; MOZ_CAN_RUN_SCRIPT_BOUNDARY bool AreItemsOperable() const override;
}; };
/**
* Used for the singular, global instance of a XULCombobox which is rendered
* in the parent process and contains the options of the focused and expanded
* HTML select in a content document. This combobox should have
* id=ContentSelectDropdown
*/
class XULContentSelectDropdownAccessible : public XULComboboxAccessible {
public:
XULContentSelectDropdownAccessible(nsIContent* aContent, DocAccessible* aDoc)
: XULComboboxAccessible(aContent, aDoc) {}
// Accessible
virtual Accessible* Parent() const override;
};
} // namespace a11y } // namespace a11y
} // namespace mozilla } // namespace mozilla

View file

@ -212,7 +212,7 @@ pref("app.update.langpack.enabled", true);
// This feature is also affected by // This feature is also affected by
// `app.update.multiSessionInstallLockout.timeoutMs`, which is in the branding // `app.update.multiSessionInstallLockout.timeoutMs`, which is in the branding
// section. // section.
pref("app.update.multiSessionInstallLockout.enabled", true); pref("app.update.multiSessionInstallLockout.enabled", false);
#if defined(MOZ_BACKGROUNDTASKS) #if defined(MOZ_BACKGROUNDTASKS)
// The amount of time, in seconds, before background tasks time out and exit. // The amount of time, in seconds, before background tasks time out and exit.
@ -1811,8 +1811,9 @@ pref("browser.partnerlink.campaign.topsites", "amzn_2020_a1");
pref("browser.newtab.preload", true); pref("browser.newtab.preload", true);
// If an on-train limited rollout of the preonboarding modal is enabled, the // If an on-train limited rollout of the preonboarding modal is enabled, the
// percentage of the Mac, Linux, and MSIX population to enroll // percentage of the Mac, Linux, and MSIX population to enroll. Default to 25% of
pref("browser.preonboarding.onTrainRolloutPopulation", 0); // population (2500 / 10000).
pref("browser.preonboarding.onTrainRolloutPopulation", 2500);
// Mozilla Ad Routing Service (MARS) unified ads service // Mozilla Ad Routing Service (MARS) unified ads service
pref("browser.newtabpage.activity-stream.unifiedAds.tiles.enabled", true); pref("browser.newtabpage.activity-stream.unifiedAds.tiles.enabled", true);
@ -3055,9 +3056,6 @@ pref("devtools.webconsole.filter.netxhr", false);
// Webconsole autocomplete preference // Webconsole autocomplete preference
pref("devtools.webconsole.input.autocomplete",true); pref("devtools.webconsole.input.autocomplete",true);
// Show context selector in console input
pref("devtools.webconsole.input.context", true);
// Set to true to eagerly show the results of webconsole terminal evaluations // Set to true to eagerly show the results of webconsole terminal evaluations
// when they don't have side effects. // when they don't have side effects.
pref("devtools.webconsole.input.eagerEvaluation", true); pref("devtools.webconsole.input.eagerEvaluation", true);

View file

@ -146,9 +146,18 @@ customElements.define(
} }
get hasNoPermissions() { get hasNoPermissions() {
const { strings, showIncognitoCheckbox } = const {
this.notification.options.customElementOptions; strings,
return !(showIncognitoCheckbox || strings.msgs.length); showIncognitoCheckbox,
showTechnicalAndInteractionCheckbox,
} = this.notification.options.customElementOptions;
return !(
strings.msgs.length ||
this.#dataCollectionPermissions?.msg ||
showIncognitoCheckbox ||
showTechnicalAndInteractionCheckbox
);
} }
get domainsSet() { get domainsSet() {
@ -171,9 +180,25 @@ customElements.define(
return strings.fullDomainsList.msgIdIndex === idx; return strings.fullDomainsList.msgIdIndex === idx;
} }
/**
* @returns {{idx: number, collectsTechnicalAndInteractionData: boolean}}
* An object with information about data collection permissions for the UI.
*/
get #dataCollectionPermissions() {
if (!this.notification?.options?.customElementOptions) {
return undefined;
}
const { strings } = this.notification.options.customElementOptions;
return strings.dataCollectionPermissions;
}
render() { render() {
const { strings, showIncognitoCheckbox, isUserScriptsRequest } = const {
this.notification.options.customElementOptions; strings,
showIncognitoCheckbox,
showTechnicalAndInteractionCheckbox,
isUserScriptsRequest,
} = this.notification.options.customElementOptions;
const { textEl, introEl, permsListEl } = this; const { textEl, introEl, permsListEl } = this;
@ -240,6 +265,28 @@ customElements.define(
permsListEl.appendChild(item); permsListEl.appendChild(item);
} }
if (this.#dataCollectionPermissions?.msg) {
let item = doc.createElementNS(HTML_NS, "li");
item.classList.add(
"webext-perm-granted",
"webext-data-collection-perm-granted"
);
item.textContent = this.#dataCollectionPermissions.msg;
permsListEl.appendChild(item);
}
// Add a checkbox for the "technicalAndInteraction" optional data
// collection permission.
if (showTechnicalAndInteractionCheckbox) {
let item = doc.createElementNS(HTML_NS, "li");
item.classList.add(
"webext-perm-optional",
"webext-data-collection-perm-optional"
);
item.appendChild(this.#createTechnicalAndInteractionDataCheckbox());
permsListEl.appendChild(item);
}
if (showIncognitoCheckbox) { if (showIncognitoCheckbox) {
let item = doc.createElementNS(HTML_NS, "li"); let item = doc.createElementNS(HTML_NS, "li");
item.classList.add( item.classList.add(
@ -367,6 +414,28 @@ customElements.define(
); );
return checkboxEl; return checkboxEl;
} }
#createTechnicalAndInteractionDataCheckbox() {
const { grantTechnicalAndInteractionDataCollection } =
this.notification.options.customElementOptions;
const checkboxEl = this.ownerDocument.createXULElement("checkbox");
checkboxEl.label = lazy.PERMISSION_L10N.formatValueSync(
"webext-perms-description-data-long-technicalAndInteraction"
);
checkboxEl.checked = grantTechnicalAndInteractionDataCollection;
checkboxEl.addEventListener("CheckboxStateChange", () => {
// NOTE: the popupnotification instances will be reused
// and so the callback function is destructured here to
// avoid this custom element to prevent it from being
// garbage collected.
const { onTechnicalAndInteractionDataChanged } =
this.notification.options.customElementOptions;
onTechnicalAndInteractionDataChanged?.(checkboxEl.checked);
});
return checkboxEl;
}
} }
); );

View file

@ -1185,8 +1185,12 @@ var gIdentityHandler = {
}, },
setURI(uri) { setURI(uri) {
if (uri instanceof Ci.nsINestedURI) { // Unnest the URI, turning "view-source:https://example.com" into
uri = uri.QueryInterface(Ci.nsINestedURI).innermostURI; // "https://example.com" for example. "about:" URIs are a special exception
// here, as some of them have a hidden moz-safe-about inner URI we do not
// want to unnest.
while (uri instanceof Ci.nsINestedURI && !uri.schemeIs("about")) {
uri = uri.QueryInterface(Ci.nsINestedURI).innerURI;
} }
this._uri = uri; this._uri = uri;
@ -1197,7 +1201,7 @@ var gIdentityHandler = {
this._uriHasHost = false; this._uriHasHost = false;
} }
if (uri.schemeIs("about") || uri.schemeIs("moz-safe-about")) { if (uri.schemeIs("about")) {
let module = E10SUtils.getAboutModule(uri); let module = E10SUtils.getAboutModule(uri);
if (module) { if (module) {
let flags = module.getURIFlags(uri); let flags = module.getURIFlags(uri);

View file

@ -4541,7 +4541,10 @@ function undoCloseTab(aIndex, sourceWindowSSId) {
if (SessionStore.getSavedTabGroup(lastClosedTabGroupId)) { if (SessionStore.getSavedTabGroup(lastClosedTabGroupId)) {
group = SessionStore.openSavedTabGroup( group = SessionStore.openSavedTabGroup(
lastClosedTabGroupId, lastClosedTabGroupId,
targetWindow targetWindow,
{
source: "recent",
}
); );
} else { } else {
group = SessionStore.undoCloseTabGroup( group = SessionStore.undoCloseTabGroup(
@ -4917,17 +4920,26 @@ var RestoreLastSessionObserver = {
!PrivateBrowsingUtils.isWindowPrivate(window) !PrivateBrowsingUtils.isWindowPrivate(window)
) { ) {
Services.obs.addObserver(this, "sessionstore-last-session-cleared", true); Services.obs.addObserver(this, "sessionstore-last-session-cleared", true);
Services.obs.addObserver(
this,
"sessionstore-last-session-re-enable",
true
);
goSetCommandEnabled("Browser:RestoreLastSession", true); goSetCommandEnabled("Browser:RestoreLastSession", true);
} else if (SessionStore.willAutoRestore) { } else if (SessionStore.willAutoRestore) {
document.getElementById("Browser:RestoreLastSession").hidden = true; document.getElementById("Browser:RestoreLastSession").hidden = true;
} }
}, },
observe() { observe(aSubject, aTopic) {
// The last session can only be restored once so there's switch (aTopic) {
// no way we need to re-enable our menu item. case "sessionstore-last-session-cleared":
Services.obs.removeObserver(this, "sessionstore-last-session-cleared"); goSetCommandEnabled("Browser:RestoreLastSession", false);
goSetCommandEnabled("Browser:RestoreLastSession", false); break;
case "sessionstore-last-session-re-enable":
goSetCommandEnabled("Browser:RestoreLastSession", true);
break;
}
}, },
QueryInterface: ChromeUtils.generateQI([ QueryInterface: ChromeUtils.generateQI([

View file

@ -16,6 +16,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
<!-- These localization links are not automatically applied to any XHR <!-- These localization links are not automatically applied to any XHR
response body and must be applied manually as well. They are included response body and must be applied manually as well. They are included
so that viewing the file directly shows the results. --> so that viewing the file directly shows the results. -->

View file

@ -319,12 +319,20 @@
</menupopup> </menupopup>
<menupopup id="sidebar-history-menu"> <menupopup id="sidebar-history-menu">
<menuitem data-l10n-id="sidebar-history-sort-by-date" <html:h1 data-l10n-id="sidebar-history-sort-by-heading"
id="sidebar-history-sort-by-heading"/>
<menuitem data-l10n-id="sidebar-history-sort-option-date"
id="sidebar-history-sort-by-date" id="sidebar-history-sort-by-date"
type="checkbox"/> type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-by-site" <menuitem data-l10n-id="sidebar-history-sort-option-site"
id="sidebar-history-sort-by-site" id="sidebar-history-sort-by-site"
type="checkbox"/> type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-option-date-and-site"
id="sidebar-history-sort-by-date-and-site"
type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-option-last-visited"
id="sidebar-history-sort-by-last-visited"
type="checkbox"/>
<menuseparator/> <menuseparator/>
<menuitem data-l10n-id="sidebar-history-clear" <menuitem data-l10n-id="sidebar-history-clear"
id="sidebar-history-clear"/> id="sidebar-history-clear"/>

View file

@ -10,8 +10,7 @@ document.addEventListener(
() => { () => {
const lazy = {}; const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
TabGroupMetrics: TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
}); });
let mainPopupSet = document.getElementById("mainPopupSet"); let mainPopupSet = document.getElementById("mainPopupSet");
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
@ -135,11 +134,12 @@ document.addEventListener(
let tabGroup = gBrowser.getTabGroupById(tabGroupId); let tabGroup = gBrowser.getTabGroupById(tabGroupId);
// Tabs need to be removed by their owning `Tabbrowser` or else // Tabs need to be removed by their owning `Tabbrowser` or else
// there are errors. // there are errors.
tabGroup.ownerGlobal.gBrowser.removeTabGroup(tabGroup, { tabGroup.ownerGlobal.gBrowser.removeTabGroup(
isUserTriggered: true, tabGroup,
telemetrySource: lazy.TabMetrics.userTriggeredContext(
lazy.TabGroupMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU, lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU
}); )
);
} }
break; break;
@ -147,14 +147,18 @@ document.addEventListener(
case "saved-tab-group-context-menu_openInThisWindow": case "saved-tab-group-context-menu_openInThisWindow":
{ {
let { tabGroupId } = event.target.parentElement.triggerNode.dataset; let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
SessionStore.openSavedTabGroup(tabGroupId, window); SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
});
} }
break; break;
case "saved-tab-group-context-menu_openInNewWindow": case "saved-tab-group-context-menu_openInNewWindow":
{ {
// TODO Bug 1940112: "Open Group in New Window" should directly restore saved tab groups into a new window // TODO Bug 1940112: "Open Group in New Window" should directly restore saved tab groups into a new window
let { tabGroupId } = event.target.parentElement.triggerNode.dataset; let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window); let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
});
gBrowser.replaceGroupWithWindow(tabGroup); gBrowser.replaceGroupWithWindow(tabGroup);
} }
break; break;

View file

@ -151,13 +151,14 @@ security.ui.protectionspopup:
type: boolean type: boolean
bugs: bugs:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews: data_reviews:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
notification_emails: notification_emails:
- wwen@mozilla.com - wwen@mozilla.com
- emz@mozilla.com - emz@mozilla.com
expires: expires:
140 146
open_protectionspopup_cfr: open_protectionspopup_cfr:
type: event type: event
@ -389,13 +390,14 @@ security.ui.protectionspopup:
type: string type: string
bugs: bugs:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews: data_reviews:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
notification_emails: notification_emails:
- wwen@mozilla.com - wwen@mozilla.com
- emz@mozilla.com - emz@mozilla.com
expires: expires:
140 146
smartblockembeds_shown: smartblockembeds_shown:
type: counter type: counter
@ -403,13 +405,14 @@ security.ui.protectionspopup:
How many times the SmartBlock placeholders are shown on the page How many times the SmartBlock placeholders are shown on the page
bugs: bugs:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews: data_reviews:
- https://bugzil.la/1920735 - https://bugzil.la/1920735
notification_emails: notification_emails:
- wwen@mozilla.com - wwen@mozilla.com
- emz@mozilla.com - emz@mozilla.com
expires: expires:
140 146
browser.engagement: browser.engagement:
bookmarks_toolbar_bookmark_added: bookmarks_toolbar_bookmark_added:

View file

@ -164,7 +164,6 @@
<toolbartabstop/> <toolbartabstop/>
<html:div id="urlbar" <html:div id="urlbar"
popover="manual" popover="manual"
context=""
focused="true" focused="true"
pageproxystate="invalid" pageproxystate="invalid"
unifiedsearchbutton-available="" unifiedsearchbutton-available=""

View file

@ -60,6 +60,14 @@ const knownUnshownImages = [
file: "chrome://global/skin/icons/highlights.svg", file: "chrome://global/skin/icons/highlights.svg",
platforms: ["win", "linux", "macosx"], platforms: ["win", "linux", "macosx"],
intermittentShown: ["win", "linux"], intermittentShown: ["win", "linux"],
// this file is not loaded in beta since the pref is only
// turned on in nightly
intermittentNotLoaded: Services.prefs.getBoolPref(
"browser.tabs.groups.smart.enabled",
true
)
? []
: ["win", "linux", "macosx"],
}, },
]; ];

View file

@ -79,6 +79,12 @@ var tests = [
hostForDisplay: "chrome://global/content/mozilla.html", hostForDisplay: "chrome://global/content/mozilla.html",
hasSubview: false, hasSubview: false,
}, },
{
name: "about:logo with nested moz-safe-about:logo",
location: "about:logo",
hostForDisplay: "about:logo",
hasSubview: false,
},
]; ];
add_task(async function test() { add_task(async function test() {

View file

@ -28,6 +28,7 @@ export const AdditionalCTA = ({ content, handleAction }) => {
<div className={className}> <div className={className}>
<Localized text={content.additional_button?.label}> <Localized text={content.additional_button?.label}>
<button <button
id="additional_button"
className={`${buttonStyle} additional-cta`} className={`${buttonStyle} additional-cta`}
onClick={handleAction} onClick={handleAction}
value="additional_button" value="additional_button"

View file

@ -401,6 +401,7 @@ export const SecondaryCTA = props => {
</Localized> </Localized>
<Localized text={props.content[targetElement].label}> <Localized text={props.content[targetElement].label}>
<button <button
id="secondary_button"
className={buttonStyling} className={buttonStyling}
value={targetElement} value={targetElement}
disabled={isDisabled(props.content.secondary_button?.disabled)} disabled={isDisabled(props.content.secondary_button?.disabled)}

View file

@ -142,12 +142,14 @@ const SubmenuButtonInner = ({ content, handleAction }) => {
return ( return (
<Localized text={content.submenu_button.label ?? {}}> <Localized text={content.submenu_button.label ?? {}}>
<button <button
id="submenu_button"
className={`submenu-button ${isPrimary ? "primary" : "secondary"}`} className={`submenu-button ${isPrimary ? "primary" : "secondary"}`}
value="submenu_button" value="submenu_button"
onClick={onClick} onClick={onClick}
ref={ref} ref={ref}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isSubmenuExpanded} aria-expanded={isSubmenuExpanded}
aria-labelledby={`${content.submenu_button.attached_to} submenu_button`}
/> />
</Localized> </Localized>
); );

View file

@ -468,6 +468,7 @@ const SecondaryCTA = props => {
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("span", null)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("span", null)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: props.content[targetElement].label text: props.content[targetElement].label
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", { }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "secondary_button",
className: buttonStyling, className: buttonStyling,
value: targetElement, value: targetElement,
disabled: isDisabled(props.content.secondary_button?.disabled), disabled: isDisabled(props.content.secondary_button?.disabled),
@ -1828,6 +1829,7 @@ const AdditionalCTA = ({
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: content.additional_button?.label text: content.additional_button?.label
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", { }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "additional_button",
className: `${buttonStyle} additional-cta`, className: `${buttonStyle} additional-cta`,
onClick: handleAction, onClick: handleAction,
value: "additional_button", value: "additional_button",
@ -1991,12 +1993,14 @@ const SubmenuButtonInner = ({
return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, { return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: content.submenu_button.label ?? {} text: content.submenu_button.label ?? {}
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", { }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "submenu_button",
className: `submenu-button ${isPrimary ? "primary" : "secondary"}`, className: `submenu-button ${isPrimary ? "primary" : "secondary"}`,
value: "submenu_button", value: "submenu_button",
onClick: onClick, onClick: onClick,
ref: ref, ref: ref,
"aria-haspopup": "menu", "aria-haspopup": "menu",
"aria-expanded": isSubmenuExpanded "aria-expanded": isSubmenuExpanded,
"aria-labelledby": `${content.submenu_button.attached_to} submenu_button`
})); }));
}; };

View file

@ -564,6 +564,10 @@
"minumum": 0, "minumum": 0,
"exclusiveMaximum": 10 "exclusiveMaximum": 10
}, },
"dismissable": {
"description": "Should the infobar include an X dismiss button, defaults to true",
"type": "boolean"
},
"buttons": { "buttons": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -24,6 +24,10 @@
"minumum": 0, "minumum": 0,
"exclusiveMaximum": 10 "exclusiveMaximum": 10
}, },
"dismissable": {
"description": "Should the infobar include an X dismiss button, defaults to true",
"type": "boolean"
},
"buttons": { "buttons": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -1692,6 +1692,7 @@ const MESSAGES = () => {
style: "secondary", style: "secondary",
label: { label: {
marginBlock: "0 -8px", marginBlock: "0 -8px",
string_id: "split-dismiss-button-default-label",
}, },
}, },
tiles: { tiles: {
@ -1960,6 +1961,7 @@ const MESSAGES = () => {
style: "secondary", style: "secondary",
label: { label: {
marginBlock: "0 -8px", marginBlock: "0 -8px",
string_id: "split-dismiss-button-default-label",
}, },
}, },
tiles: { tiles: {

View file

@ -45,7 +45,9 @@ class InfoBarNotification {
priority, priority,
eventCallback: this.infobarCallback, eventCallback: this.infobarCallback,
}, },
content.buttons.map(b => this.formatButtonConfig(b)) content.buttons.map(b => this.formatButtonConfig(b)),
false,
content.dismissable
); );
this.addImpression(); this.addImpression();

View file

@ -177,3 +177,78 @@ add_task(async function prevent_multiple_messages() {
infobar.notification.closeButton.click(); infobar.notification.closeButton.click();
Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification"); Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
}); });
add_task(async function default_dismissable_button_shows() {
let message = (await CFRMessageProvider.getMessages()).find(
m => m.id === "INFOBAR_ACTION_86"
);
Assert.ok(message, "Found the message");
// Use the base message which has no dismissable property by default.
let dispatchStub = sinon.stub();
let infobar = await InfoBar.showInfoBarMessage(
BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
message,
dispatchStub
);
Assert.ok(
infobar.notification.closeButton,
"Default message should display a close button"
);
infobar.notification.closeButton.click();
await BrowserTestUtils.waitForCondition(
() => infobar.notification === null,
"Wait for default message notification to be dismissed."
);
});
add_task(
async function non_dismissable_notification_does_not_show_close_button() {
let baseMessage = (await CFRMessageProvider.getMessages()).find(
m => m.id === "INFOBAR_ACTION_86"
);
Assert.ok(baseMessage, "Found the base message");
let message = {
...baseMessage,
content: {
...baseMessage.content,
dismissable: false,
},
};
// Add a footer button we can close the infobar with
message.content.buttons.push({
label: "Cancel",
action: {
type: "CANCEL",
},
});
let dispatchStub = sinon.stub();
let infobar = await InfoBar.showInfoBarMessage(
BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
message,
dispatchStub
);
Assert.ok(
!infobar.notification.closeButton,
"Non-dismissable message should not display a close button"
);
let cancelButton = infobar.notification.querySelector(
".footer-button:not(.primary)"
);
Assert.ok(cancelButton, "Non-primary footer button exists");
cancelButton.click();
await BrowserTestUtils.waitForCondition(
() => infobar.notification === null,
"Wait for default message notification to close."
);
}
);

View file

@ -67,18 +67,57 @@ export const ContentAnalysis = {
isInitialized: false, isInitialized: false,
// Maps string UserActionId to { userActionId, requestTokenSet, timer } or /**
// { userActionId, requestTokenSet, notification } * @typedef {object} NotificationInfo - information about the busy dialog itself that is showing
* @property {*} [close] - Method to close the native notification
* @property {BrowsingContext} [dialogBrowsingContext] - browsing context where the
* confirm() dialog is shown
*/
/**
* @typedef {object} BusyDialogInfo - information about a busy dialog that is either showing or will
* will be shown after a delay.
* @property {string} userActionId - The userActionId of the request
* @property {Set<string>} requestTokenSet - The set of requestTokens associated with the userActionId
* @property {*} [timer] - Result of a setTimeout() call that can be used to cancel the showing of the busy
* dialog if it has not been displayed yet.
* @property {NotificationInfo} [notification] - Information about the busy dialog that is being shown.
*/
/**
* @type {Map<string, BusyDialogInfo>}
*
* Maps string UserActionId to info about the busy dialog.
*/
userActionToBusyDialogMap: new Map(), userActionToBusyDialogMap: new Map(),
/** /**
* @type {Map<string, {browsingContext: BrowsingContext, resourceNameOrOperationType: object}>} * @typedef {object} ResourceNameOrOperationType
* @property {string} [name] - the name of the resource
* @property {number} [operationType] - the type of operation
*/
/**
* @typedef {object} RequestInfo
* @property {BrowsingContext} browsingContext - browsing context where the request was sent from
* @property {ResourceNameOrOperationType} resourceNameOrOperationType - name of the operation
*/
/**
* @type {Map<string, RequestInfo>}
*/ */
requestTokenToRequestInfo: new Map(), requestTokenToRequestInfo: new Map(),
/**
* @type {Set<string>}
*/
warnDialogRequestTokens: new Set(),
/** /**
* Registers for various messages/events that will indicate the * Registers for various messages/events that will indicate the
* need for communicating something to the user. * need for communicating something to the user.
*
* @param {Window} window - The window to monitor
*/ */
initialize(window) { initialize(window) {
if (!lazy.gContentAnalysis.isActive) { if (!lazy.gContentAnalysis.isActive) {
@ -180,6 +219,17 @@ export const ContentAnalysis = {
break; break;
} }
case "quit-application": { case "quit-application": {
// We're quitting, so respond false to all WARN dialogs.
let requestTokensToCancel = this.warnDialogRequestTokens;
// Clear this first so the handler showing the dialog will know not
// to call respondToWarnDialog() again.
this.warnDialogRequestTokens = new Set();
for (let warnDialogRequestToken of requestTokensToCancel) {
lazy.gContentAnalysis.respondToWarnDialog(
warnDialogRequestToken,
false
);
}
this.uninitialize(); this.uninitialize();
break; break;
} }
@ -256,6 +306,12 @@ export const ContentAnalysis = {
} }
}, },
/**
* Shows the panel that indicates that DLP is active.
*
* @param {Element} element The toolbarbutton the user has clicked on
* @param {panelUI} panelUI Maintains state for the main menu panel
*/
async showPanel(element, panelUI) { async showPanel(element, panelUI) {
element.ownerDocument.l10n.setAttributes( element.ownerDocument.l10n.setAttributes(
lazy.PanelMultiView.getViewNode( lazy.PanelMultiView.getViewNode(
@ -268,6 +324,11 @@ export const ContentAnalysis = {
panelUI.showSubView("content-analysis-panel", element); panelUI.showSubView("content-analysis-panel", element);
}, },
/**
* Closes a busy dialog
*
* @param {BusyDialogInfo?} caView - the busy dialog to close
*/
_disconnectFromView(caView) { _disconnectFromView(caView) {
if (!caView) { if (!caView) {
return; return;
@ -301,6 +362,16 @@ export const ContentAnalysis = {
} }
}, },
/**
* Shows either a dialog or native notification or both, depending on the values of
* _SHOW_DIALOGS and _SHOW_NOTIFICATIONS.
*
* @param {string} aMessage - Message to show
* @param {BrowsingContext} aBrowsingContext - BrowsingContext to show the dialog in.
* @param {number} aTimeout - timeout for closing the native notification. 0 indicates it is
* not automatically closed.
* @returns {NotificationInfo?} - information about the native notification, if it has been shown.
*/
_showMessage(aMessage, aBrowsingContext, aTimeout = 0) { _showMessage(aMessage, aBrowsingContext, aTimeout = 0) {
if (this._SHOW_DIALOGS) { if (this._SHOW_DIALOGS) {
Services.prompt.asyncAlert( Services.prompt.asyncAlert(
@ -331,6 +402,12 @@ export const ContentAnalysis = {
return null; return null;
}, },
/**
* Whether the notification should block browser interaction.
*
* @param {number} aAnalysisType The type of DLP analysis being done.
* @returns {boolean}
*/
_shouldShowBlockingNotification(aAnalysisType) { _shouldShowBlockingNotification(aAnalysisType) {
return !( return !(
aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded || aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded ||
@ -338,8 +415,13 @@ export const ContentAnalysis = {
); );
}, },
// This function also transforms the nameOrOperationType so we won't have to /**
// look it up again. * This function also transforms the nameOrOperationType so we won't have to
* look it up again.
*
* @param {ResourceNameOrOperationType} nameOrOperationType
* @returns {string}
*/
_getResourceNameFromNameOrOperationType(nameOrOperationType) { _getResourceNameFromNameOrOperationType(nameOrOperationType) {
if (!nameOrOperationType.name) { if (!nameOrOperationType.name) {
let l10nId = undefined; let l10nId = undefined;
@ -374,8 +456,7 @@ export const ContentAnalysis = {
* line. This is used to add more context to the message * line. This is used to add more context to the message
* if a file is being uploaded rather than just the name * if a file is being uploaded rather than just the name
* of the file. * of the file.
* @returns {object} An object with either a name property that can be used as-is, or * @returns {ResourceNameOrOperationType}
* an operationType property.
*/ */
_getResourceNameOrOperationTypeFromRequest(aRequest, aStandalone) { _getResourceNameOrOperationTypeFromRequest(aRequest, aStandalone) {
if ( if (
@ -395,6 +476,14 @@ export const ContentAnalysis = {
return { operationType: aRequest.operationTypeForDisplay }; return { operationType: aRequest.operationTypeForDisplay };
}, },
/**
* Sets up an "operation is in progress" dialog to be shown after a delay,
* unless one is already showing for this userActionId.
*
* @param {nsIContentAnalysisRequest} aRequest
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @param {BrowsingContext} aBrowsingContext
*/
_queueSlowCAMessage( _queueSlowCAMessage(
aRequest, aRequest,
aResourceNameOrOperationType, aResourceNameOrOperationType,
@ -433,6 +522,12 @@ export const ContentAnalysis = {
}, slowTimeoutMs); }, slowTimeoutMs);
}, },
/**
* Removes the Slow CA message, if it is showing
*
* @param {string} aUserActionId The user action ID to remove
* @param {string} aRequestToken The request token to remove
*/
_removeSlowCAMessage(aUserActionId, aRequestToken) { _removeSlowCAMessage(aUserActionId, aRequestToken) {
let entry = this.userActionToBusyDialogMap.get(aUserActionId); let entry = this.userActionToBusyDialogMap.get(aUserActionId);
if (!entry) { if (!entry) {
@ -456,8 +551,9 @@ export const ContentAnalysis = {
}, },
/** /**
* Gets all the requests that are still in progress.
* *
* @returns {Iterable<{browsingContext: BrowsingContext, resourceNameOrOperationType: object}>} Information about the requests that are still in progress * @returns {Iterable<RequestInfo>} Information about the requests that are still in progress
*/ */
_getAllSlowCARequestInfos() { _getAllSlowCARequestInfos() {
return this.userActionToBusyDialogMap return this.userActionToBusyDialogMap
@ -469,6 +565,11 @@ export const ContentAnalysis = {
/** /**
* Show a message to the user to indicate that a CA request is taking * Show a message to the user to indicate that a CA request is taking
* a long time. * a long time.
*
* @param {string} aOperation Name of the operation
* @param {nsIContentAnalysisRequest} aRequest The request that is taking a long time
* @param {string} aBodyMessage Message to show in the body of the alert
* @param {BrowsingContext} aBrowsingContext BrowsingContext to show the alert in
*/ */
_showSlowCAMessage(aOperation, aRequest, aBodyMessage, aBrowsingContext) { _showSlowCAMessage(aOperation, aRequest, aBodyMessage, aBrowsingContext) {
if (!this._shouldShowBlockingNotification(aOperation)) { if (!this._shouldShowBlockingNotification(aOperation)) {
@ -489,6 +590,13 @@ export const ContentAnalysis = {
); );
}, },
/**
* Gets the dialog message to show for the Slow CA dialog.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @param {number} aNumRequests
* @returns {string}
*/
_getSlowDialogMessage(aResourceNameOrOperationType, aNumRequests) { _getSlowDialogMessage(aResourceNameOrOperationType, aNumRequests) {
if (aResourceNameOrOperationType.name) { if (aResourceNameOrOperationType.name) {
let label = let label =
@ -524,6 +632,12 @@ export const ContentAnalysis = {
return this.l10n.formatValueSync(l10nId, { agent: lazy.agentName }); return this.l10n.formatValueSync(l10nId, { agent: lazy.agentName });
}, },
/**
* Gets the dialog message to show when the request has an error.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @returns {string}
*/
_getErrorDialogMessage(aResourceNameOrOperationType) { _getErrorDialogMessage(aResourceNameOrOperationType) {
if (aResourceNameOrOperationType.name) { if (aResourceNameOrOperationType.name) {
return this.l10n.formatValueSync( return this.l10n.formatValueSync(
@ -552,6 +666,16 @@ export const ContentAnalysis = {
} }
return this.l10n.formatValueSync(l10nId); return this.l10n.formatValueSync(l10nId);
}, },
/**
* Show the Slow CA blocking dialog.
*
* @param {BrowsingContext} aBrowsingContext
* @param {string} aUserActionId
* @param {string} aRequestToken
* @param {string} aBodyMessage
* @returns {NotificationInfo}
*/
_showSlowCABlockingMessage( _showSlowCABlockingMessage(
aBrowsingContext, aBrowsingContext,
aUserActionId, aUserActionId,
@ -589,12 +713,12 @@ export const ContentAnalysis = {
// in which case we need to cancel the request. // in which case we need to cancel the request.
if (this.requestTokenToRequestInfo.delete(aRequestToken)) { if (this.requestTokenToRequestInfo.delete(aRequestToken)) {
// TODO: Is this useful? I think no. // TODO: Is this useful? I think no.
this._removeSlowCAMessage({}, aRequestToken);
this._removeSlowCAMessage(aUserActionId, aRequestToken); this._removeSlowCAMessage(aUserActionId, aRequestToken);
lazy.gContentAnalysis.cancelRequestsByRequestToken(aRequestToken); lazy.gContentAnalysis.cancelRequestsByRequestToken(aRequestToken);
} }
}); });
return { return {
requestToken: aRequestToken,
dialogBrowsingContext: aBrowsingContext, dialogBrowsingContext: aBrowsingContext,
}; };
}, },
@ -602,7 +726,14 @@ export const ContentAnalysis = {
/** /**
* Show a message to the user to indicate the result of a CA request. * Show a message to the user to indicate the result of a CA request.
* *
* @returns {object} a notification object (if shown) * @param {object} aResourceNameOrOperationType
* @param {BrowsingContext} aBrowsingContext
* @param {string} aRequestToken
* @param {string} aUserActionId
* @param {number} aCAResult
* @param {bool} aIsAgentResponse
* @param {number} aRequestCancelError
* @returns {NotificationInfo?} a notification object (if shown)
*/ */
async _showCAResult( async _showCAResult(
aResourceNameOrOperationType, aResourceNameOrOperationType,
@ -635,6 +766,7 @@ export const ContentAnalysis = {
case Ci.nsIContentAnalysisResponse.eWarn: { case Ci.nsIContentAnalysisResponse.eWarn: {
let allow = false; let allow = false;
try { try {
this.warnDialogRequestTokens.add(aRequestToken);
const result = await Services.prompt.asyncConfirmEx( const result = await Services.prompt.asyncConfirmEx(
aBrowsingContext, aBrowsingContext,
Ci.nsIPromptService.MODAL_TYPE_TAB, Ci.nsIPromptService.MODAL_TYPE_TAB,
@ -664,7 +796,13 @@ export const ContentAnalysis = {
// the request is still active. // the request is still active.
allow = false; allow = false;
} }
lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow); // Note that the shutdown code in the "quit-application" handler
// may have cleared out warnDialogRequestTokens and responded
// to the request already, so don't call respondToWarnDialog()
// if aRequestToken is not in warnDialogRequestTokens.
if (this.warnDialogRequestTokens.delete(aRequestToken)) {
lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow);
}
return null; return null;
} }
case Ci.nsIContentAnalysisResponse.eBlock: { case Ci.nsIContentAnalysisResponse.eBlock: {
@ -832,6 +970,8 @@ export const ContentAnalysis = {
/** /**
* Returns the correct text for warn dialog contents. * Returns the correct text for warn dialog contents.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
*/ */
async _warnDialogText(aResourceNameOrOperationType) { async _warnDialogText(aResourceNameOrOperationType) {
const caInfo = await lazy.gContentAnalysis.getDiagnosticInfo(); const caInfo = await lazy.gContentAnalysis.getDiagnosticInfo();

View file

@ -5,7 +5,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
with Files("**"): with Files("**"):
BUG_COMPONENT = ("Toolkit", "General") BUG_COMPONENT = ("Firefox", "Data Loss Prevention")
EXTRA_JS_MODULES += [ EXTRA_JS_MODULES += [
"content/ContentAnalysis.sys.mjs", "content/ContentAnalysis.sys.mjs",

View file

@ -10,6 +10,8 @@ class TestNoToolbarChanges(MarionetteTestCase):
Test that toolbar widgets remain in the same order over several restarts of the browser Test that toolbar widgets remain in the same order over several restarts of the browser
""" """
have_seen_import_button = False
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.marionette.set_context("chrome") self.marionette.set_context("chrome")
@ -37,13 +39,30 @@ class TestNoToolbarChanges(MarionetteTestCase):
navbarPlacements, navbarPlacements,
msg="AREA_NAVBAR placements are as expected", msg="AREA_NAVBAR placements are as expected",
) )
actualBookmarkPlacements = self.get_area_widgets("AREA_BOOKMARKS")
bookmarkPlacements = self.get_area_default_placements("AREA_BOOKMARKS") bookmarkPlacements = self.get_area_default_placements("AREA_BOOKMARKS")
bookmarkPlacements.insert(0, "import-button") # The import button is added lazily on startup, so we can't predict
self.assertEqual( # whether it'll be here. Turning it off via prefs=[] annotations on the
self.get_area_widgets("AREA_BOOKMARKS"), # test also doesn't work
bookmarkPlacements, # (https://bugzilla.mozilla.org/show_bug.cgi?id=1959688).
msg="AREA_BOOKMARKS placements are as expected", # So we simply accept placements either with or without the button - but
# if we ever see the button we should keep seeing it.
self.have_seen_import_button = (
self.have_seen_import_button or "import-button" in actualBookmarkPlacements
) )
if self.have_seen_import_button:
self.assertEqual(
actualBookmarkPlacements,
["import-button"] + bookmarkPlacements,
msg="AREA_BOOKMARKS placements are as expected",
)
else:
self.assertEqual(
actualBookmarkPlacements,
bookmarkPlacements,
msg="AREA_BOOKMARKS placements are as expected",
)
self.assertEqual( self.assertEqual(
self.get_area_widgets("AREA_ADDONS"), self.get_area_widgets("AREA_ADDONS"),
self.get_area_default_placements("AREA_ADDONS"), self.get_area_default_placements("AREA_ADDONS"),

View file

@ -13,6 +13,8 @@ module.exports = {
TabContext: true, TabContext: true,
Window: true, Window: true,
clickModifiersFromEvent: true, clickModifiersFromEvent: true,
getExtTabGroupIdForInternalTabGroupId: true,
getInternalTabGroupIdForExtTabGroupId: true,
makeWidgetId: true, makeWidgetId: true,
openOptionsPage: true, openOptionsPage: true,
replaceUrlInTab: true, replaceUrlInTab: true,

View file

@ -135,6 +135,49 @@ global.replaceUrlInTab = (gBrowser, tab, uri) => {
return loaded; return loaded;
}; };
// The tabs.Tab.groupId type in the public extension API is an integer,
// but tabbrowser's tab group ID are strings. This handles the conversion.
//
// tabbrowser.addTabGroup() generates the internal tab group ID as follows:
// internal group id = `${Date.now()}-${Math.round(Math.random() * 100)}`;
// After dropping the hyphen ("-"), the result can be coerced into a safe
// integer.
//
// As a safeguard, in case the format changes, we fall back to maintaining
// an internal mapping (that never gets cleaned up).
// This may change in https://bugzilla.mozilla.org/show_bug.cgi?id=1960104
const fallbackTabGroupIdMap = new Map();
let nextFallbackTabGroupId = 1;
global.getExtTabGroupIdForInternalTabGroupId = groupIdStr => {
const parsedTabId = /^(\d{13})-(\d{1,3})$/.exec(groupIdStr);
if (parsedTabId) {
const groupId = parsedTabId[1] * 1000 + parseInt(parsedTabId[2], 10);
if (Number.isSafeInteger(groupId)) {
return groupId;
}
}
// Fall back.
let fallbackGroupId = fallbackTabGroupIdMap.get(groupIdStr);
if (!fallbackGroupId) {
fallbackGroupId = nextFallbackTabGroupId++;
fallbackTabGroupIdMap.set(groupIdStr, fallbackGroupId);
}
return fallbackGroupId;
};
global.getInternalTabGroupIdForExtTabGroupId = groupId => {
if (Number.isSafeInteger(groupId) && groupId >= 1e15) {
// 16 digits - this inverts getExtTabGroupIdForInternalTabGroupId.
const groupIdStr = `${Math.floor(groupId / 1000)}-${groupId % 1000}`;
return groupIdStr;
}
for (let [groupIdStr, fallbackGroupId] of fallbackTabGroupIdMap) {
if (fallbackGroupId === groupId) {
return groupIdStr;
}
}
return null;
};
/** /**
* Manages tab-specific and window-specific context data, and dispatches * Manages tab-specific and window-specific context data, and dispatches
* tab select events across all windows. * tab select events across all windows.
@ -878,6 +921,11 @@ class Tab extends TabBase {
return successor ? tabTracker.getId(successor) : -1; return successor ? tabTracker.getId(successor) : -1;
} }
get groupId() {
const { group } = this.nativeTab;
return group ? getExtTabGroupIdForInternalTabGroupId(group.id) : -1;
}
/** /**
* Converts session store data to an object compatible with the return value * Converts session store data to an object compatible with the return value
* of the convert() method, representing that data. * of the convert() method, representing that data.

View file

@ -160,6 +160,7 @@ const allProperties = new Set([
"autoDiscardable", "autoDiscardable",
"discarded", "discarded",
"favIconUrl", "favIconUrl",
"groupId",
"hidden", "hidden",
"isArticle", "isArticle",
"mutedInfo", "mutedInfo",
@ -260,13 +261,17 @@ this.tabs = class extends ExtensionAPIPersistent {
}), }),
onMoved({ fire }) { onMoved({ fire }) {
let { tabManager } = this.extension; let { tabManager } = this.extension;
/**
* @param {CustomEvent} event
*/
let moveListener = event => { let moveListener = event => {
let nativeTab = event.originalTarget; let nativeTab = event.originalTarget;
let { previousTabState, currentTabState } = event.detail;
if (tabManager.canAccessTab(nativeTab)) { if (tabManager.canAccessTab(nativeTab)) {
fire.async(tabTracker.getId(nativeTab), { fire.async(tabTracker.getId(nativeTab), {
windowId: windowTracker.getId(nativeTab.ownerGlobal), windowId: windowTracker.getId(nativeTab.ownerGlobal),
fromIndex: event.detail, fromIndex: previousTabState.tabIndex,
toIndex: nativeTab._tPos, toIndex: currentTabState.tabIndex,
}); });
} }
}; };
@ -461,6 +466,15 @@ this.tabs = class extends ExtensionAPIPersistent {
needed.push("discarded"); needed.push("discarded");
} else if (event.type == "TabBrowserDiscarded") { } else if (event.type == "TabBrowserDiscarded") {
needed.push("discarded"); needed.push("discarded");
} else if (event.type === "TabGrouped") {
needed.push("groupId");
} else if (event.type === "TabUngrouped") {
if (event.originalTarget.group) {
// If there is still a group, that means that the group changed,
// so TabGrouped will also fire. Ignore to avoid duplicate events.
return;
}
needed.push("groupId");
} else if (event.type == "TabShow") { } else if (event.type == "TabShow") {
needed.push("hidden"); needed.push("hidden");
} else if (event.type == "TabHide") { } else if (event.type == "TabHide") {
@ -527,6 +541,10 @@ this.tabs = class extends ExtensionAPIPersistent {
listeners.set("TabBrowserInserted", listener); listeners.set("TabBrowserInserted", listener);
listeners.set("TabBrowserDiscarded", listener); listeners.set("TabBrowserDiscarded", listener);
} }
if (filter.properties.has("groupId")) {
listeners.set("TabGrouped", listener);
listeners.set("TabUngrouped", listener);
}
if (filter.properties.has("hidden")) { if (filter.properties.has("hidden")) {
listeners.set("TabShow", listener); listeners.set("TabShow", listener);
listeners.set("TabHide", listener); listeners.set("TabHide", listener);
@ -1661,6 +1679,113 @@ this.tabs = class extends ExtensionAPIPersistent {
let nativeTab = getTabOrActive(tabId); let nativeTab = getTabOrActive(tabId);
nativeTab.linkedBrowser.goBack(false); nativeTab.linkedBrowser.goBack(false);
}, },
group(options) {
let nativeTabs = getNativeTabsFromIDArray(options.tabIds);
let window = windowTracker.getWindow(
options.createProperties?.windowId ?? Window.WINDOW_ID_CURRENT,
context
);
const windowIsPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
for (const nativeTab of nativeTabs) {
if (
PrivateBrowsingUtils.isWindowPrivate(nativeTab.ownerGlobal) !==
windowIsPrivate
) {
if (windowIsPrivate) {
throw new ExtensionError(
"Cannot move non-private tabs to private window"
);
}
throw new ExtensionError(
"Cannot move private tabs to non-private window"
);
}
}
function unpinTabsBeforeGrouping() {
for (const nativeTab of nativeTabs) {
nativeTab.ownerGlobal.gBrowser.unpinTab(nativeTab);
}
}
let group;
if (options.groupId == null) {
// By default, tabs are appended after all other tabs in the
// window. But if we are grouping tabs within a window, ideally the
// tabs should just be grouped without moving positions.
// TODO bug 1939214: when addTabGroup inserts tabs at the front as
// needed (instead of always appending), simplify this logic.
const tabInWin = nativeTabs.find(t => t.ownerGlobal === window);
let insertBefore = tabInWin;
if (tabInWin?.group) {
if (tabInWin.group.tabs[0] === tabInWin) {
// When tabInWin is at the front of a tab group, insert before
// the tab group (instead of after it).
insertBefore = tabInWin.group;
} else {
insertBefore = insertBefore.group.nextElementSibling;
}
}
unpinTabsBeforeGrouping();
group = window.gBrowser.addTabGroup(nativeTabs, { insertBefore });
// Note: group is never null, because the only condition for which
// it could be null is when all tabs are pinned, and we are already
// explicitly unpinning them before moving.
} else {
group = window.gBrowser.getTabGroupById(
getInternalTabGroupIdForExtTabGroupId(options.groupId)
);
if (!group) {
throw new ExtensionError(`No group with id: ${options.groupId}`);
}
unpinTabsBeforeGrouping();
// When moving tabs within the same window, try to maintain their
// relative positions.
const tabsBefore = [];
const tabsAfter = [];
const firstTabInGroup = group.tabs[0];
for (const nativeTab of nativeTabs) {
if (
nativeTab.ownerGlobal === window &&
nativeTab._tPos < firstTabInGroup._tPos
) {
tabsBefore.push(nativeTab);
} else {
tabsAfter.push(nativeTab);
}
}
if (tabsBefore.length) {
window.gBrowser.moveTabsBefore(tabsBefore, firstTabInGroup);
}
if (tabsAfter.length) {
group.addTabs(tabsAfter);
}
}
return getExtTabGroupIdForInternalTabGroupId(group.id);
},
ungroup(tabIds) {
const nativeTabs = getNativeTabsFromIDArray(tabIds);
// Ungroup tabs while trying to preserve the relative order of tabs
// within the tab strip as much as possible. This is not always
// possible, e.g. when a tab group is only partially ungrouped.
const ungroupOrder = new DefaultMap(() => []);
for (const nativeTab of nativeTabs) {
if (nativeTab.group) {
ungroupOrder.get(nativeTab.group).push(nativeTab);
}
}
for (const [group, tabs] of ungroupOrder) {
// Preserve original order of ungrouped tabs.
tabs.sort((a, b) => a._tPos - b._tPos);
if (tabs[0] === tabs[0].group.tabs[0]) {
// The tab is the front of the tab group, so insert before
// current tab group to preserve order.
tabs[0].ownerGlobal.gBrowser.moveTabsBefore(tabs, group);
} else {
tabs[0].ownerGlobal.gBrowser.moveTabsAfter(tabs, group);
}
}
},
}, },
}; };
return tabsApi; return tabsApi;

View file

@ -227,6 +227,12 @@
"optional": true, "optional": true,
"minimum": -1, "minimum": -1,
"description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise." "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."
},
"groupId": {
"type": "integer",
"optional": true,
"minimum": -1,
"description": "The ID of the group that the tab belongs to. $(ref:tabGroups.TAB_GROUP_ID_NONE) (-1) if the tab does not belong to a tab group."
} }
} }
}, },
@ -429,6 +435,7 @@
"autoDiscardable", "autoDiscardable",
"discarded", "discarded",
"favIconUrl", "favIconUrl",
"groupId",
"hidden", "hidden",
"isArticle", "isArticle",
"mutedInfo", "mutedInfo",
@ -832,6 +839,12 @@
"optional": true, "optional": true,
"description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab." "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab."
}, },
"groupId": {
"type": "integer",
"minimum": -1,
"optional": true,
"description": "The ID of the group that the tabs are in, or $(ref:tabGroups.TAB_GROUP_ID_NONE) (-1) for ungrouped tabs."
},
"screen": { "screen": {
"choices": [ "choices": [
{ {
@ -1591,6 +1604,83 @@
"parameters": [] "parameters": []
} }
] ]
},
{
"name": "group",
"type": "function",
"description": "Adds one or more tabs to a specified group, or if no group is specified, adds the given tabs to a newly created group.",
"async": "callback",
"parameters": [
{
"name": "options",
"type": "object",
"properties": {
"tabIds": {
"description": "The tab ID or list of tab IDs to add to the specified group.",
"choices": [
{ "type": "integer", "minimum": 0 },
{
"type": "array",
"items": { "type": "integer", "minimum": 0 },
"minItems": 1
}
]
},
"groupId": {
"type": "integer",
"description": "The ID of the group to add the tabs to. If not specified, a new group will be created.",
"minimum": 0,
"optional": true
},
"createProperties": {
"type": "object",
"optional": true,
"description": "Configurations for creating a group. Cannot be used if groupId is already specified.",
"properties": {
"windowId": {
"type": "integer",
"minimum": -2,
"optional": true,
"description": "The window of the new group. Defaults to the current window."
}
}
}
}
},
{
"type": "function",
"name": "callback",
"optional": true,
"parameters": [
{
"name": "groupId",
"type": "integer",
"minimum": 0,
"description": "The ID of the group that the tabs were added to."
}
]
}
]
},
{
"name": "ungroup",
"type": "function",
"description": "Removes one or more tabs from their respective groups. If any groups become empty, they are deleted.",
"async": true,
"parameters": [
{
"name": "tabIds",
"description": "The tab ID or list of tab IDs to remove from their respective groups.",
"choices": [
{ "type": "integer", "minimum": 0 },
{
"type": "array",
"items": { "type": "integer", "minimum": 0 },
"minItems": 1
}
]
}
]
} }
], ],
"events": [ "events": [

View file

@ -538,6 +538,10 @@ https_first_disabled = true
["browser_ext_tabs_goBack_goForward.js"] ["browser_ext_tabs_goBack_goForward.js"]
["browser_ext_tabs_groupId.js"]
["browser_ext_tabs_group_ungroup.js"]
["browser_ext_tabs_hide.js"] ["browser_ext_tabs_hide.js"]
https_first_disabled = true https_first_disabled = true
@ -579,6 +583,8 @@ skip-if = [
["browser_ext_tabs_onUpdated_filter.js"] ["browser_ext_tabs_onUpdated_filter.js"]
["browser_ext_tabs_onUpdated_groupId.js"]
["browser_ext_tabs_opener.js"] ["browser_ext_tabs_opener.js"]
["browser_ext_tabs_printPreview.js"] ["browser_ext_tabs_printPreview.js"]

View file

@ -0,0 +1,144 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
function resetInternalExtensionFallbackTabGroupIdMap() {
const { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs"
);
// ExtensionParent.apiManager.global may be unset if there has not been any
// attempt to load any extension yet.
// We need to use eval() because the variables are declared with const/let,
// which cannot simply be read as properties from the global.
ExtensionParent.apiManager.global?.eval(`
"use strict";
// Reset to initial values as set by ext-browser.js
fallbackTabGroupIdMap.clear();
nextFallbackTabGroupId = 1;
`);
}
function getInternalExtensionFallbackTabGroupIdMapEntries() {
const { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs"
);
return Array.from(
ExtensionParent.apiManager.global.eval("fallbackTabGroupIdMap").entries()
);
}
async function getCurrentTabGroupId() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
browser.test.assertTrue(
Number.isSafeInteger(tab.groupId),
`groupId is safe integer: ${tab.groupId}`
);
browser.test.assertTrue(
tab.groupId >= 0,
`groupId is non-negative integer: ${tab.groupId}`
);
const tabRepeated = await browser.tabs.get(tab.id);
browser.test.assertEq(
tab.groupId,
tabRepeated.groupId,
"groupId should consistently return the same value"
);
const tabs = await browser.tabs.query({ groupId: tab.groupId });
browser.test.assertEq(
tabs.length,
1,
"tabs.query({ groupId }) found the one and only tab in that group"
);
browser.test.assertEq(tab.id, tabs[0].id, "Got expected tab");
browser.test.sendMessage("ext_groupId", tab.groupId);
},
});
await extension.startup();
const groupId = await extension.awaitMessage("ext_groupId");
await extension.unload();
return groupId;
}
// This test checks whether the tabs.Tab.groupId field has a meaningful value
// derived from a tabbrowser tab group.
add_task(async function tabs_Tab_groupId() {
resetInternalExtensionFallbackTabGroupIdMap();
const tab1 = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"https://example.com/?1",
true
);
const tab2 = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"https://example.com/?2",
true
);
gBrowser.selectedTab = tab2;
// Without a given ID, tabbrowser generates a new ID.
const group1 = gBrowser.addTabGroup([tab1]);
// The following ID differs from the tabbrowser format:
const group2 = gBrowser.addTabGroup([tab2], { id: "non-numericid" });
is(group2.id, "non-numericid", "Created group with non-numeric ID");
info("Testing tab group with internally generated ID");
gBrowser.selectedTab = tab1;
const generatedId1 = await getCurrentTabGroupId();
const generatedId2 = await getCurrentTabGroupId();
is(generatedId1, generatedId2, "Same groupId across extensions (generated)");
info("Testing tab group with ID, not matching internally generated IDs");
gBrowser.selectedTab = tab2;
const strangeId1 = await getCurrentTabGroupId();
const strangeId2 = await getCurrentTabGroupId();
is(strangeId1, strangeId2, "Same groupId across extensions (non-internal)");
isnot(generatedId1, strangeId1, "Independent tab groups have different ID");
// Move tab to another tab group.
const group3 = gBrowser.addTabGroup([tab2], { id: "another-non-numericid" });
is(group3.id, "another-non-numericid", "Updated group with non-numeric ID");
const strangeId3 = await getCurrentTabGroupId();
isnot(strangeId2, strangeId3, "New tab group has different tab group ID");
// Verify assumptions behind the above tests, to make sure that the test
// passes for the expected reasons.
is(
`${Math.floor(generatedId1 / 1000)}-${generatedId1 % 1000}`,
group1.id,
"groupId in extension API derived from internally-generated group ID"
);
is(
strangeId1,
1,
"groupId in extension API is next available ID (non-numericid)"
);
is(
strangeId3,
2,
"groupId in extension API is next available ID (another-non-numericid)"
);
BrowserTestUtils.removeTab(tab1);
BrowserTestUtils.removeTab(tab2);
// The main purpose of this check is to verify that we do not store mappings
// for internal groupIds that are in the expected format. Additionally, this
// also shows that the map is not cleaned up even after the group disappears.
Assert.deepEqual(
getInternalExtensionFallbackTabGroupIdMapEntries(),
[
["non-numericid", strangeId1],
["another-non-numericid", strangeId3],
],
"The internal fallback map contains only non-numeric IDs"
);
resetInternalExtensionFallbackTabGroupIdMap();
});

View file

@ -0,0 +1,438 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
// TODO bug 1938594: group_across_private_browsing_windows sometimes triggers
// this error. See https://bugzilla.mozilla.org/show_bug.cgi?id=1938594#c1
PromiseTestUtils.allowMatchingRejectionsGlobally(
/Unexpected undefined tabState for onMoveToNewWindow/
);
add_task(async function group_ungroup_and_index() {
const extension = ExtensionTestUtils.loadExtension({
files: {
"tab1.htm": "<title>tab1.html</title>",
"tab2.htm": "<title>tab2.html</title>",
"tab3.htm": "<title>tab3.html</title>",
},
async background() {
const { id: tabId1 } = await browser.tabs.create({ url: "tab1.htm" });
const { id: tabId2 } = await browser.tabs.create({ url: "tab2.htm " });
const { id: tabId3 } = await browser.tabs.create({ url: "tab3.htm" });
async function assertAllTabExpectations(expectations, desc) {
const tabs = await Promise.all([
browser.tabs.get(tabId1),
browser.tabs.get(tabId2),
browser.tabs.get(tabId3),
]);
const { indexes, groupIds, ...rest } = expectations;
if (Object.keys(rest).length) {
// Sanity check, so we don't miss expectations due to typos.
throw new Error(
`Unexpected keys in expectations: ${Object.keys(rest)} for ${desc}`
);
}
browser.test.assertDeepEq(
indexes,
tabs.map(t => t.index),
`${desc} : Tabs should be at expected indexes`
);
browser.test.assertDeepEq(
groupIds,
tabs.toSorted((a, b) => a.index - b.index).map(t => t.groupId),
`${desc} : Tab groupIds in order of tabs`
);
}
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [-1, -1, -1] },
"Initial tab indexes and group IDs before group()"
);
const groupId1 = await browser.tabs.group({ tabIds: tabId1 });
const groupId2 = await browser.tabs.group({ tabIds: [tabId2, tabId3] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId1, groupId2, groupId2] },
"Got two groups after group(tab1) + group([tab2, tab3])"
);
// It should be possible to ungroup tabs from different groups.
// And their position should not move, since these tabs are not in the
// middle of a tab group.
await browser.tabs.ungroup([tabId3, tabId1]);
await browser.tabs.ungroup(tabId2);
await browser.test.assertRejects(
browser.tabs.group({ tabIds: tabId3, groupId: groupId1 }),
`No group with id: ${groupId1}`,
"After ungrouping, the groupId should no longer be valid"
);
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [-1, -1, -1] },
"Tab groups should still be at their original position in the tab"
);
// Now grouping two tabs that are apart - they should be together.
const groupId3 = await browser.tabs.group({ tabIds: [tabId1, tabId3] });
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId3, groupId3, -1] },
"Tabs in same tab group must be next to each other"
);
// Join existing tab group - now we should have three in the tab group.
const groupId4 = await browser.tabs.group({
tabIds: [tabId2],
groupId: groupId3,
});
browser.test.assertEq(
groupId3,
groupId4,
"group() with a groupId parameter returns given groupId"
);
// Joining an existing group should not have changed positions.
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId3, groupId3, groupId3] },
"Tab order did not change when joining adjacent group"
);
await browser.tabs.ungroup([tabId1, tabId2, tabId3]);
// Ungrouping of the group should not have changed positions either,
// despite the list of tabIds passed to ungroup() being out of order.
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [-1, -1, -1] },
"Tab order did not change when ungrouping with out-of-order tabIds"
);
// Group tabs together. Tab positions should match given order.
const groupId5 = await browser.tabs.group({
tabIds: [tabId1, tabId2, tabId3],
});
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId5, groupId5, groupId5] },
"Tab order matches order of tabIds passed to tabs.group()"
);
// Move the leftmost tab to a new group. That tab should still be
// positioned at the left of the original tab group.
const groupId6 = await browser.tabs.group({ tabIds: [tabId1] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId6, groupId5, groupId5] },
"Leftmost tab should still be ordered before the original tab group"
);
// Join an existing group (from the left). Position should not change.
await browser.tabs.group({ tabIds: [tabId1], groupId: groupId5 });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId5, groupId5, groupId5] },
"Tab order did not change when joining a tab group from the left"
);
await browser.test.assertRejects(
browser.tabs.group({ tabIds: tabId3, groupId: groupId6 }),
`No group with id: ${groupId6}`,
"Old groupId should be invalid after last tab was moved from group"
);
// Move the middle tab to a new group. That tab should be at the right.
const groupId7 = await browser.tabs.group({ tabIds: [tabId2] });
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId5, groupId5, groupId7] },
"group() on middle tab in existing group appears on the right"
);
// Prepare: tabId1 and tabId2 together at the left, followed by tabId3.
const groupId8 = await browser.tabs.group({ tabIds: [tabId1, tabId2] });
await browser.tabs.ungroup(tabId3);
// When tabId2 is moved to a new group, it should stay in the middle,
// meaning that the tab was inserted after its original tab group.
// In particular, it should not move to the end of the tab strip.
const groupId9 = await browser.tabs.group({ tabIds: [tabId2] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId8, groupId9, -1] },
"group() on rightmost tab should appear after original tab group"
);
await browser.tabs.remove(tabId1);
await browser.tabs.remove(tabId2);
await browser.tabs.remove(tabId3);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_with_windowId() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: windowId, tabs: initialTabs } = await browser.windows.create(
{}
);
browser.test.assertEq(1, initialTabs.length, "Got window with 1 tab");
const { id: tabId1 } = await browser.tabs.create({});
const { id: tabId2 } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({
tabIds: [tabId2, tabId1],
createProperties: { windowId },
});
browser.test.assertDeepEq(
Array.from(await browser.tabs.query({ groupId: groupId1 }), t => t.id),
[tabId2, tabId1],
"Moved tabs to group"
);
browser.test.assertDeepEq(
Array.from(await browser.tabs.query({ windowId }), t => t.id),
[initialTabs[0].id, tabId2, tabId1],
"Moved tabs to group in new window (next to initial tab)"
);
await browser.windows.remove(windowId);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_adopt_from_multiple_windows() {
const extension = ExtensionTestUtils.loadExtension({
incognitoOverride: "spanning",
async background() {
const {
id: windowId1,
tabs: [{ id: tabId1 }],
} = await browser.windows.create({});
const {
id: windowId2,
tabs: [{ id: tabId2 }],
} = await browser.windows.create({});
const {
id: windowId3,
tabs: [{ id: tabId3 }],
} = await browser.windows.create({});
// This confirms that group() can adapt tabs from different windows.
const groupId = await browser.tabs.group({
tabIds: [tabId2, tabId3],
createProperties: { windowId: windowId1 },
});
await browser.test.assertRejects(
browser.windows.get(windowId2),
`Invalid window ID: ${windowId2}`,
"Window closes when group() adopts the last tab of the window"
);
// We just confirmed that window2 is closed, do the same for window3.
await browser.test.assertRejects(
browser.tabs.group({
tabIds: tabId1,
createProperties: { windowId: windowId3 },
}),
`Invalid window ID: ${windowId3}`,
"group() cannot adapt groups from a closed window"
);
browser.test.assertDeepEq(
// Note: tabId1 is missing because the above group() rejected.
[tabId2, tabId3],
Array.from(await browser.tabs.query({ groupId }), tab => tab.id),
"All specified tabIds should now belong to the given group"
);
await browser.windows.remove(windowId1);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_across_private_browsing_windows() {
const extension = ExtensionTestUtils.loadExtension({
incognitoOverride: "spanning",
async background() {
const privateWin = await browser.windows.create({ incognito: true });
const normalWin = await browser.windows.create({ incognito: false });
const otherPrivateWin = await browser.windows.create({ incognito: true });
const otherNormalWin = await browser.windows.create({ incognito: false });
// Mixture of private and non-private tabIDs, so we can easily verify
// whether we inadvertently move some of the tabs.
const privateTab = await browser.tabs.create({ windowId: privateWin.id });
const privateAndNonPrivateTabs = [
privateTab,
await browser.tabs.create({ windowId: normalWin.id }),
await browser.tabs.create({ windowId: privateWin.id }),
await browser.tabs.create({ windowId: normalWin.id }),
];
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
}),
"Cannot move private tabs to non-private window",
"Should not be able to move private+non-private tabs to current window"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
createProperties: { windowId: otherPrivateWin.id },
}),
"Cannot move non-private tabs to private window",
"Should not be able to move non-private tab to private window"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
createProperties: { windowId: otherNormalWin.id },
}),
"Cannot move private tabs to non-private window",
"Should not be able to move private tab to non-private window"
);
const reply = await browser.runtime.sendMessage("@no_private", {
privateWindowId: privateWin.id,
privateTabId: privateTab.id,
});
browser.test.assertEq("no_private:done", reply, "Reply from other ext");
for (const tab of privateAndNonPrivateTabs) {
const actualTab = await browser.tabs.get(tab.id);
browser.test.assertEq(
tab.windowId,
actualTab.windowId,
"Tab should not have moved to a different window"
);
browser.test.assertEq(
tab.windowId,
actualTab.windowId,
"Tab should not have moved within its window"
);
browser.test.assertEq(
tab.groupId,
-1,
"Tab should not have joined a group"
);
}
// Now check that we can actually group tabs in private windows.
const groupId = await browser.tabs.group({
tabIds: privateTab.id,
createProperties: { windowId: otherPrivateWin.id },
});
const updatedPrivateTab = await browser.tabs.get(privateTab.id);
browser.test.assertEq(
groupId,
updatedPrivateTab.groupId,
"group() succeeded with private tab"
);
browser.test.assertEq(
otherPrivateWin.id,
updatedPrivateTab.windowId,
"Private tab is now part of the destination private window"
);
await browser.windows.remove(privateWin.id);
await browser.windows.remove(normalWin.id);
await browser.windows.remove(otherPrivateWin.id);
await browser.windows.remove(otherNormalWin.id);
browser.test.sendMessage("done");
},
});
const extensionWithoutPrivateAccess = ExtensionTestUtils.loadExtension({
manifest: { browser_specific_settings: { gecko: { id: "@no_private" } } },
background() {
browser.runtime.onMessageExternal.addListener(async data => {
const { privateWindowId, privateTabId } = data;
const { id: normalTabId } = await browser.tabs.create({});
await browser.test.assertRejects(
browser.tabs.group({ tabIds: [privateTabId] }),
`Invalid tab ID: ${privateTabId}`,
"@no_private should not be able to group private tabs"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: [normalTabId],
createProperties: { windowId: privateWindowId },
}),
`Invalid window ID: ${privateWindowId}`,
"@no_private should not see private windows"
);
await browser.tabs.remove(normalTabId);
return "no_private:done"; // Checked by sender.
});
},
});
await extensionWithoutPrivateAccess.startup();
await extension.startup();
await extension.awaitMessage("done");
await extensionWithoutPrivateAccess.unload();
await extension.unload();
});
add_task(async function group_pinned_tab() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: tabId1 } = await browser.tabs.create({ pinned: true });
const { id: tabId2 } = await browser.tabs.create({ pinned: true });
const groupId = await browser.tabs.group({ tabIds: [tabId1] });
browser.test.assertTrue(groupId > 0, `group() created group: ${groupId}`);
const tab1 = await browser.tabs.get(tabId1);
browser.test.assertFalse(tab1.pinned, "group() unpins tab 1");
browser.test.assertEq(groupId, tab1.groupId, "group() grouped tab 1");
const groupId2 = await browser.tabs.group({ tabIds: [tabId2], groupId });
browser.test.assertEq(groupId, groupId2, "group() existing group");
const tab2 = await browser.tabs.get(tabId2);
browser.test.assertFalse(tab2.pinned, "group() unpins tab 2");
browser.test.assertEq(groupId, tab2.groupId, "Tab joined existing group");
await browser.tabs.remove(tabId1);
await browser.tabs.remove(tabId2);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_pinned_tab_from_different_window() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: tabId } = await browser.tabs.create({ pinned: true });
const { id: windowId } = await browser.windows.create({});
const groupId = await browser.tabs.group({
tabIds: [tabId],
createProperties: { windowId },
});
browser.test.assertTrue(groupId > 0, `group() created group: ${groupId}`);
const tab = await browser.tabs.get(tabId);
browser.test.assertFalse(tab.pinned, "group() unpins tab");
browser.test.assertEq(groupId, tab.groupId, "group() grouped tab");
browser.test.assertEq(windowId, tab.windowId, "Moved tab to new window");
await browser.windows.remove(windowId);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});

View file

@ -0,0 +1,109 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
add_task(async function onUpdated_when_grouping_and_ungrouping() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener(
(tabId, changeInfo, tab) => {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
},
{ properties: ["groupId"] }
);
const { id: tabId } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.ungroup(tabId);
const groupId2 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ groupId: groupId1 }, { groupId: -1 }, { groupId: groupId2 }],
changes,
"Observed tabs.onUpdated events after group(), ungroup() and group()"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function onUpdated_when_grouping_and_regrouping() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener(
(tabId, changeInfo, tab) => {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
},
{ properties: ["groupId"] }
);
const { id: tabId } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({ tabIds: [tabId] });
const groupId2 = await browser.tabs.group({ tabIds: [tabId] });
const groupId3 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ groupId: groupId1 }, { groupId: groupId2 }, { groupId: groupId3 }],
changes,
"Observed tabs.onUpdated events after group() and regrouping repeatedly"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function onUpdated_when_grouping_pinned_tab() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.groupId) {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
} else if (changeInfo.pinned != null) {
changes.push(changeInfo);
}
});
const { id: tabId } = await browser.tabs.create({ pinned: true });
const groupId = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ pinned: true }, { pinned: false }, { groupId: groupId }],
changes,
"Observed tabs.onUpdated events after group() of pinned tab"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});

View file

@ -33,6 +33,7 @@ let expectedBackgroundApisTargetSpecific = [
"tabs.getZoomSettings", "tabs.getZoomSettings",
"tabs.goBack", "tabs.goBack",
"tabs.goForward", "tabs.goForward",
"tabs.group",
"tabs.highlight", "tabs.highlight",
"tabs.insertCSS", "tabs.insertCSS",
"tabs.move", "tabs.move",
@ -58,6 +59,7 @@ let expectedBackgroundApisTargetSpecific = [
"tabs.setZoom", "tabs.setZoom",
"tabs.setZoomSettings", "tabs.setZoomSettings",
"tabs.toggleReaderMode", "tabs.toggleReaderMode",
"tabs.ungroup",
"tabs.update", "tabs.update",
"tabs.warmup", "tabs.warmup",
"windows.CreateType", "windows.CreateType",

View file

@ -37,13 +37,22 @@ const HISTORY_MAP_L10N_IDS = {
}, },
}; };
/**
* When sorting by date or site, each card "item" is a single visit.
*
* When sorting by date *and* site, each card "item" is a mapping of site
* domains to their respective list of visits.
*
* @typedef {HistoryVisit | [string, HistoryVisit[]]} CardItem
*/
/** /**
* A list of visits displayed on a card. * A list of visits displayed on a card.
* *
* @typedef {object} CardEntry * @typedef {object} CardEntry
* *
* @property {string} domain * @property {string} domain
* @property {HistoryVisit[]} items * @property {CardItem[]} items
* @property {string} l10nId * @property {string} l10nId
*/ */
@ -127,7 +136,7 @@ export class HistoryController {
/** /**
* Update cached history. * Update cached history.
* *
* @param {Map<CacheKey, HistoryVisit[]>} [historyMap] * @param {CachedHistory} [historyMap]
* If provided, performs an update using the given data (instead of fetching * If provided, performs an update using the given data (instead of fetching
* it from the db). * it from the db).
*/ */
@ -146,7 +155,19 @@ export class HistoryController {
} }
for (const { items } of entries) { for (const { items } of entries) {
for (const item of items) { for (const item of items) {
this.#normalizeVisit(item); switch (sortOption) {
case "datesite": {
// item is a [ domain, visit[] ] entry.
const [, visits] = item;
for (const visit of visits) {
this.#normalizeVisit(visit);
}
break;
}
default:
// item is a single visit.
this.#normalizeVisit(item);
}
} }
} }
this.historyCache = { entries, searchQuery, sortOption }; this.historyCache = { entries, searchQuery, sortOption };
@ -203,6 +224,11 @@ export class HistoryController {
return this.#getVisitsForDate(historyMap); return this.#getVisitsForDate(historyMap);
case "site": case "site":
return this.#getVisitsForSite(historyMap); return this.#getVisitsForSite(historyMap);
case "datesite":
this.#setTodaysDate();
return this.#getVisitsForDateSite(historyMap);
case "lastvisited":
return this.#getVisitsForLastVisited(historyMap);
default: default:
return []; return [];
} }
@ -285,9 +311,9 @@ export class HistoryController {
* Get a list of visits per day for each day on this month, excluding today * Get a list of visits per day for each day on this month, excluding today
* and yesterday. * and yesterday.
* *
* @param {Map<number, HistoryVisit[]>} cachedHistory * @param {CachedHistory} cachedHistory
* The history cache to process. * The history cache to process.
* @returns {HistoryVisit[][]} * @returns {CardItem[]}
* A list of visits for each day. * A list of visits for each day.
*/ */
#getVisitsByDay(cachedHistory) { #getVisitsByDay(cachedHistory) {
@ -313,9 +339,9 @@ export class HistoryController {
* excluding yesterday's visits if yesterday happens to fall on the previous * excluding yesterday's visits if yesterday happens to fall on the previous
* month. * month.
* *
* @param {Map<number, HistoryVisit[]>} cachedHistory * @param {CachedHistory} cachedHistory
* The history cache to process. * The history cache to process.
* @returns {HistoryVisit[][]} * @returns {CardItem[]}
* A list of visits for each month. * A list of visits for each month.
*/ */
#getVisitsByMonth(cachedHistory) { #getVisitsByMonth(cachedHistory) {
@ -332,6 +358,12 @@ export class HistoryController {
const month = this.placesQuery.getStartOfMonthTimestamp(date); const month = this.placesQuery.getStartOfMonthTimestamp(date);
if (month !== previousMonth) { if (month !== previousMonth) {
visitsPerMonth.push(visits); visitsPerMonth.push(visits);
} else if (this.sortOption === "datesite") {
// CardItem type is currently Map<string, HistoryVisit[]>.
visitsPerMonth[visitsPerMonth.length - 1] = this.#mergeMaps(
visitsPerMonth.at(-1),
visits
);
} else { } else {
visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth
.at(-1) .at(-1)
@ -372,6 +404,22 @@ export class HistoryController {
); );
} }
/**
* Merge two maps of (domain: string) => HistoryVisit[] into a single map.
*
* @param {Map<string, HistoryVisit[]>} oldMap
* @param {Map<string, HistoryVisit[]>} newMap
* @returns {Map<string, HistoryVisit[]>}
*/
#mergeMaps(oldMap, newMap) {
const map = new Map(oldMap);
for (const [domain, newVisits] of newMap) {
const oldVisits = map.get(domain);
map.set(domain, oldVisits?.concat(newVisits) ?? newVisits);
}
return map;
}
/** /**
* Get a list of visits, sorted by site, in alphabetical order. * Get a list of visits, sorted by site, in alphabetical order.
* *
@ -386,6 +434,75 @@ export class HistoryController {
})).sort((a, b) => a.domain.localeCompare(b.domain)); })).sort((a, b) => a.domain.localeCompare(b.domain));
} }
/**
* Get a list of visits, sorted by date and site, in reverse chronological
* order.
*
* @param {Map<number, Map<string, HistoryVisit[]>>} historyMap
* @returns {CardEntry[]}
*/
#getVisitsForDateSite(historyMap) {
const entries = [];
const visitsFromToday = this.#getVisitsFromToday(historyMap);
const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap);
const visitsByDay = this.#getVisitsByDay(historyMap);
const visitsByMonth = this.#getVisitsByMonth(historyMap);
/**
* Sorts items alphabetically by domain name.
*
* @param {[string, HistoryVisit[]][]} items
* @returns {[string, HistoryVisit[]][]} The items in sorted order.
*/
function sortItems(items) {
return items.sort(([aDomain], [bDomain]) =>
aDomain.localeCompare(bDomain)
);
}
// Add visits from today and yesterday.
if (visitsFromToday.length) {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"],
items: sortItems(visitsFromToday),
});
}
if (visitsFromYesterday.length) {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"],
items: sortItems(visitsFromYesterday),
});
}
// Add visits from this month, grouped by day.
visitsByDay.forEach(visits => {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"],
items: sortItems([...visits]),
});
});
// Add visits from previous months, grouped by month.
visitsByMonth.forEach(visits => {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"],
items: sortItems([...visits]),
});
});
return entries;
}
/**
* Get a list of visits sorted by recency.
*
* @param {HistoryVisit[]} items
* @returns {CardEntry[]}
*/
#getVisitsForLastVisited(items) {
return [{ items }];
}
async #fetchHistory() { async #fetchHistory() {
return this.placesQuery.getHistory({ return this.placesQuery.getHistory({
daysOld: 60, daysOld: 60,

View file

@ -10,6 +10,7 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {}; const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
ContentAnalysisUtils: "resource://gre/modules/ContentAnalysisUtils.sys.mjs",
EveryWindow: "resource:///modules/EveryWindow.sys.mjs", EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
@ -552,6 +553,16 @@ export const GenAI = {
} }
}); });
// For Content Analysis, we need to specify the URL that the data is being sent to.
// In this case it's not the URL in the browsingContext (like it is in other cases),
// but the URL of the chatProvider is close enough to where the content will eventually
// be sent.
lazy.ContentAnalysisUtils.setupContentAnalysisEventsForTextElement(
textAreaEl,
browser.browsingContext,
Services.io.newURI(lazy.chatProvider)
);
const resetHeight = () => { const resetHeight = () => {
textAreaEl.style.height = "auto"; textAreaEl.style.height = "auto";
textAreaEl.style.height = textAreaEl.scrollHeight + "px"; textAreaEl.style.height = textAreaEl.scrollHeight + "px";

View file

@ -57,20 +57,81 @@
margin: 0; margin: 0;
} }
> ul { > ul {
font-size: var(--og-main-font-size); font-size: var(--og-main-font-size);
padding-inline-start: var(--space-large); line-height: 1.15; /* Design requires 18px line-height */
} list-style-type: square;
padding-inline-start: var(--space-large);
}
li { li {
margin-block: var(--space-medium); margin-block: var(--space-medium);
padding-inline-start: 5px;
&::marker {
color: var(--border-color-deemphasized);
} }
}
> hr { > hr {
border-color: var(--border-color-card); border-color: var(--border-color-card);
} }
> p { > p {
margin-block: var(--space-medium) 0; margin-block: var(--space-medium) 0;
} }
} }
/**
* Defines the animation for the loading state of link preview keypoints
* Creates a smooth gradient animation that moves from right to left
* to indicate content is being loaded
*/
@keyframes link-preview-keypoints-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.keypoints-list {
.content-item {
margin-bottom: var(--space-xlarge);
width: 100%;
&.loading {
div {
--skeleton-loader-background-color: var(--tab-group-suggestions-loading-animation-color-1);
--skeleton-loader-motion-element-color: var(--tab-group-suggestions-loading-animation-color-2);
animation: link-preview-keypoints-loading 3s infinite;
background: linear-gradient(
100deg,
var(--skeleton-loader-background-color) 30%,
var(--skeleton-loader-motion-element-color) 50%,
var(--skeleton-loader-background-color) 70%
);
background-size: 200% 100%;
border-radius: 5px;
height: var(--og-main-font-size);
margin-bottom: 4px;
width: 100%;
/* Add non-impactful references to the CSS variables to satisfy the test browser_parsable_css */
outline-color: var(--skeleton-loader-background-color);
border-color: var(--skeleton-loader-motion-element-color);
}
div:nth-of-type(1) {
max-width: 95%;
}
div:nth-of-type(2) {
max-width: 98%;
}
div:nth-of-type(3) {
max-width: 90%;
}
}
}
}

View file

@ -26,6 +26,9 @@ class LinkPreviewCard extends MozLitElement {
// Text for the link to visit the original URL when in error state // Text for the link to visit the original URL when in error state
static VISIT_LINK_TEXT = "Visit link"; static VISIT_LINK_TEXT = "Visit link";
// Number of placeholder rows to show when loading
static PLACEHOLDER_COUNT = 3;
static properties = { static properties = {
generating: { type: Number }, // 0 = off, 1-4 = generating & dots state generating: { type: Number }, // 0 = off, 1-4 = generating & dots state
keyPoints: { type: Array }, keyPoints: { type: Array },
@ -160,13 +163,35 @@ class LinkPreviewCard extends MozLitElement {
${this.generating || this.keyPoints.length ${this.generating || this.keyPoints.length
? html` ? html`
<div class="ai-content"> <div class="ai-content">
<h3> <h3>Key points</h3>
${this.generating <ul class="keypoints-list">
? "Generating key points" + ".".repeat(this.generating - 1) ${
: "Key points"} /* All populated content items */
</h3> this.keyPoints.map(
<ul> item => html`<li class="content-item">${item}</li>`
${this.keyPoints.map(item => html`<li>${item}</li>`)} )
}
${
/* Loading placeholders with three divs each */
this.generating
? Array(
Math.max(
0,
LinkPreviewCard.PLACEHOLDER_COUNT -
this.keyPoints.length
)
)
.fill()
.map(
() =>
html` <li class="content-item loading">
<div></div>
<div></div>
<div></div>
</li>`
)
: []
}
</ul> </ul>
${this.progress >= 0 ${this.progress >= 0
? html` ? html`

View file

@ -112,6 +112,7 @@ if CONFIG["MOZ_DEBUG"] or CONFIG["MOZ_DEV_EDITION"] or CONFIG["NIGHTLY_BUILD"]:
BROWSER_CHROME_MANIFESTS += [ BROWSER_CHROME_MANIFESTS += [
"safebrowsing/content/test/browser.toml", "safebrowsing/content/test/browser.toml",
"tests/browser/browser.toml", "tests/browser/browser.toml",
"tests/browser/eval/browser.toml",
] ]
if CONFIG["MOZ_UPDATER"]: if CONFIG["MOZ_UPDATER"]:

View file

@ -3,12 +3,14 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # 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/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<?csp default-src chrome:; img-src chrome: moz-icon:; object-src 'none'; ?>
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta
http-equiv="Content-Security-Policy"
content="default-src chrome:; img-src chrome: moz-icon:; style-src chrome: 'unsafe-inline'; object-src 'none';"
/>
<title>Interactions Debug Viewer</title> <title>Interactions Debug Viewer</title>
<script <script
type="module" type="module"

View file

@ -1314,6 +1314,12 @@ var gMainPane = {
"command", "command",
this.handleDeleteAll this.handleDeleteAll
); );
Services.obs.addObserver(this, "intl:app-locales-changed");
}
destroy() {
Services.obs.removeObserver(this, "intl:app-locales-changed");
} }
handleInstallAll = async () => { handleInstallAll = async () => {
@ -1397,6 +1403,7 @@ var gMainPane = {
for (const { langTag, displayName } of this.state.languageList) { for (const { langTag, displayName } of this.state.languageList) {
const hboxRow = document.createXULElement("hbox"); const hboxRow = document.createXULElement("hbox");
hboxRow.classList.add("translations-manage-language"); hboxRow.classList.add("translations-manage-language");
hboxRow.setAttribute("data-lang-tag", langTag);
const languageLabel = document.createXULElement("label"); const languageLabel = document.createXULElement("label");
languageLabel.textContent = displayName; // The display name is already localized. languageLabel.textContent = displayName; // The display name is already localized.
@ -1558,11 +1565,41 @@ var gMainPane = {
hideError() { hideError() {
this.elements.error.hidden = true; this.elements.error.hidden = true;
} }
observe(_subject, topic, _data) {
if (topic === "intl:app-locales-changed") {
this.refreshLanguageListDisplay();
}
}
refreshLanguageListDisplay() {
try {
const languageDisplayNames =
TranslationsParent.createLanguageDisplayNames();
for (const row of this.elements.installList.children) {
const rowLangTag = row.getAttribute("data-lang-tag");
if (!rowLangTag) {
continue;
}
const label = row.querySelector("label");
if (label) {
const newDisplayName = languageDisplayNames.of(rowLangTag);
if (label.textContent !== newDisplayName) {
label.textContent = newDisplayName;
}
}
}
} catch (error) {
console.error(error);
}
}
} }
TranslationsState.create().then( TranslationsState.create().then(
state => { state => {
new TranslationsView(state); this._translationsView = new TranslationsView(state);
}, },
error => { error => {
// This error can happen when a user is not connected to the internet, or // This error can happen when a user is not connected to the internet, or
@ -2736,6 +2773,13 @@ var gMainPane = {
Services.prefs.removeObserver(PREF_CONTAINERS_EXTENSION, this); Services.prefs.removeObserver(PREF_CONTAINERS_EXTENSION, this);
Services.obs.removeObserver(this, AUTO_UPDATE_CHANGED_TOPIC); Services.obs.removeObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
Services.obs.removeObserver(this, BACKGROUND_UPDATE_CHANGED_TOPIC); Services.obs.removeObserver(this, BACKGROUND_UPDATE_CHANGED_TOPIC);
// Clean up the TranslationsView instance if it exists
if (this._translationsView) {
this._translationsView.destroy();
this._translationsView = null;
}
AppearanceChooser.destroy(); AppearanceChooser.destroy();
}, },

View file

@ -54,11 +54,12 @@ ChromeUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => {
return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]); return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]);
}); });
XPCOMUtils.defineLazyServiceGetter( ChromeUtils.defineLazyGetter(lazy, "gParentalControlsService", () =>
lazy, "@mozilla.org/parental-controls-service;1" in Cc
"gParentalControlsService", ? Cc["@mozilla.org/parental-controls-service;1"].getService(
"@mozilla.org/parental-controls-service;1", Ci.nsIParentalControlsService
"nsIParentalControlsService" )
: null
); );
XPCOMUtils.defineLazyPreferenceGetter( XPCOMUtils.defineLazyPreferenceGetter(
@ -154,8 +155,6 @@ Preferences.addAll([
{ id: "browser.urlbar.suggest.quicksuggest.nonsponsored", type: "bool" }, { id: "browser.urlbar.suggest.quicksuggest.nonsponsored", type: "bool" },
{ id: "browser.urlbar.suggest.quicksuggest.sponsored", type: "bool" }, { id: "browser.urlbar.suggest.quicksuggest.sponsored", type: "bool" },
{ id: "browser.urlbar.quicksuggest.dataCollection.enabled", type: "bool" }, { id: "browser.urlbar.quicksuggest.dataCollection.enabled", type: "bool" },
{ id: PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, type: "string" },
{ id: PREF_URLBAR_WEATHER_USER_ENABLED, type: "bool" },
// History // History
{ id: "places.history.enabled", type: "bool" }, { id: "places.history.enabled", type: "bool" },
@ -709,7 +708,7 @@ var gPrivacyPane = {
mode == Ci.nsIDNSService.MODE_TRRFIRST || mode == Ci.nsIDNSService.MODE_TRRFIRST ||
mode == Ci.nsIDNSService.MODE_TRRONLY mode == Ci.nsIDNSService.MODE_TRRONLY
) { ) {
if (lazy.gParentalControlsService.parentalControlsEnabled) { if (lazy.gParentalControlsService?.parentalControlsEnabled) {
return "preferences-doh-status-not-active"; return "preferences-doh-status-not-active";
} }
let confirmationState = Services.dns.currentTrrConfirmationState; let confirmationState = Services.dns.currentTrrConfirmationState;
@ -732,7 +731,7 @@ var gPrivacyPane = {
if ( if (
(mode == Ci.nsIDNSService.MODE_TRRFIRST || (mode == Ci.nsIDNSService.MODE_TRRFIRST ||
mode == Ci.nsIDNSService.MODE_TRRONLY) && mode == Ci.nsIDNSService.MODE_TRRONLY) &&
lazy.gParentalControlsService.parentalControlsEnabled lazy.gParentalControlsService?.parentalControlsEnabled
) { ) {
errReason = Services.dns.getTRRSkipReasonName( errReason = Services.dns.getTRRSkipReasonName(
Ci.nsITRRSkipReason.TRR_PARENTAL_CONTROL Ci.nsITRRSkipReason.TRR_PARENTAL_CONTROL

View file

@ -14,10 +14,6 @@ ChromeUtils.defineESModuleGetters(lazy, {
UserSearchEngine: "resource://gre/modules/UserSearchEngine.sys.mjs", UserSearchEngine: "resource://gre/modules/UserSearchEngine.sys.mjs",
}); });
const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
"browser.urlbar.quicksuggest.blockedDigests";
const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";
Preferences.addAll([ Preferences.addAll([
{ id: "browser.search.suggest.enabled", type: "bool" }, { id: "browser.search.suggest.enabled", type: "bool" },
{ id: "browser.urlbar.suggest.searches", type: "bool" }, { id: "browser.urlbar.suggest.searches", type: "bool" },
@ -74,9 +70,11 @@ var gSearchPane = {
Services.obs.addObserver(this, "browser-search-engine-modified"); Services.obs.addObserver(this, "browser-search-engine-modified");
Services.obs.addObserver(this, "intl:app-locales-changed"); Services.obs.addObserver(this, "intl:app-locales-changed");
Services.obs.addObserver(this, "quicksuggest-dismissals-changed");
window.addEventListener("unload", () => { window.addEventListener("unload", () => {
Services.obs.removeObserver(this, "browser-search-engine-modified"); Services.obs.removeObserver(this, "browser-search-engine-modified");
Services.obs.removeObserver(this, "intl:app-locales-changed"); Services.obs.removeObserver(this, "intl:app-locales-changed");
Services.obs.removeObserver(this, "quicksuggest-dismissals-changed");
}); });
let suggestsPref = Preferences.get("browser.search.suggest.enabled"); let suggestsPref = Preferences.get("browser.search.suggest.enabled");
@ -365,14 +363,8 @@ var gSearchPane = {
QuickSuggest.SETTINGS_UI.FULL; QuickSuggest.SETTINGS_UI.FULL;
this._updateDismissedSuggestionsStatus(); this._updateDismissedSuggestionsStatus();
Preferences.get(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST).on("change", () =>
this._updateDismissedSuggestionsStatus()
);
Preferences.get(PREF_URLBAR_WEATHER_USER_ENABLED).on("change", () =>
this._updateDismissedSuggestionsStatus()
);
setEventListener("restoreDismissedSuggestions", "command", () => setEventListener("restoreDismissedSuggestions", "command", () =>
this.restoreDismissedSuggestions() QuickSuggest.clearDismissedSuggestions()
); );
container.hidden = false; container.hidden = false;
@ -414,21 +406,9 @@ var gSearchPane = {
* Enables/disables the "Restore" button for dismissed Firefox Suggest * Enables/disables the "Restore" button for dismissed Firefox Suggest
* suggestions. * suggestions.
*/ */
_updateDismissedSuggestionsStatus() { async _updateDismissedSuggestionsStatus() {
document.getElementById("restoreDismissedSuggestions").disabled = document.getElementById("restoreDismissedSuggestions").disabled =
!Services.prefs.prefHasUserValue(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST) && !(await QuickSuggest.canClearDismissedSuggestions());
!(
Services.prefs.prefHasUserValue(PREF_URLBAR_WEATHER_USER_ENABLED) &&
!Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED)
);
},
/**
* Restores Firefox Suggest suggestions dismissed by the user.
*/
restoreDismissedSuggestions() {
Services.prefs.clearUserPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST);
Services.prefs.clearUserPref(PREF_URLBAR_WEATHER_USER_ENABLED);
}, },
handleEvent(aEvent) { handleEvent(aEvent) {
@ -481,7 +461,11 @@ var gSearchPane = {
default: default:
this._engineStore.browserSearchEngineModified(engine, data); this._engineStore.browserSearchEngineModified(engine, data);
} }
break;
} }
case "quicksuggest-dismissals-changed":
this._updateDismissedSuggestionsStatus();
break;
} }
}, },

View file

@ -13,9 +13,6 @@ const CONTAINER_ID = "firefoxSuggestContainer";
const DATA_COLLECTION_TOGGLE_ID = "firefoxSuggestDataCollectionSearchToggle"; const DATA_COLLECTION_TOGGLE_ID = "firefoxSuggestDataCollectionSearchToggle";
const LEARN_MORE_ID = "firefoxSuggestLearnMore"; const LEARN_MORE_ID = "firefoxSuggestLearnMore";
const BUTTON_RESTORE_DISMISSED_ID = "restoreDismissedSuggestions"; const BUTTON_RESTORE_DISMISSED_ID = "restoreDismissedSuggestions";
const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
"browser.urlbar.quicksuggest.blockedDigests";
const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";
// Maps `SETTINGS_UI` values to expected visibility state objects. See // Maps `SETTINGS_UI` values to expected visibility state objects. See
// `assertSuggestVisibility()` in `head.js` for info on the state objects. // `assertSuggestVisibility()` in `head.js` for info on the state objects.
@ -202,6 +199,11 @@ add_task(async function initiallyEnabled_settingsUiOfflineOnly() {
// Tests the "Restore" button for dismissed suggestions. // Tests the "Restore" button for dismissed suggestions.
add_task(async function restoreDismissedSuggestions() { add_task(async function restoreDismissedSuggestions() {
Assert.ok(
!(await QuickSuggest.canClearDismissedSuggestions()),
"Sanity check: This test expects canClearDismissedSuggestions to return false initially"
);
await openPreferencesViaOpenPreferencesAPI("search", { leaveOpen: true }); await openPreferencesViaOpenPreferencesAPI("search", { leaveOpen: true });
let doc = gBrowser.selectedBrowser.contentDocument; let doc = gBrowser.selectedBrowser.contentDocument;
@ -209,47 +211,29 @@ add_task(async function restoreDismissedSuggestions() {
addressBarSection.scrollIntoView(); addressBarSection.scrollIntoView();
let button = doc.getElementById(BUTTON_RESTORE_DISMISSED_ID); let button = doc.getElementById(BUTTON_RESTORE_DISMISSED_ID);
Assert.equal(
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""),
"",
"Block list is empty initially"
);
Assert.ok(
Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED),
"Weather suggestions are enabled initially"
);
Assert.ok(button.disabled, "Restore button is disabled initially."); Assert.ok(button.disabled, "Restore button is disabled initially.");
await QuickSuggest.blockedSuggestions.add("https://example.com/"); await QuickSuggest.blockedSuggestions.add("https://example.com/");
Assert.notEqual(
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""), Assert.ok(
"", await QuickSuggest.canClearDismissedSuggestions(),
"Block list is non-empty after adding URL" "canClearDismissedSuggestions should return true after dismissing a suggestion"
); );
Assert.ok(!button.disabled, "Restore button is enabled after blocking URL."); Assert.ok(!button.disabled, "Restore button is enabled after blocking URL.");
let clearPromise = TestUtils.topicObserved("quicksuggest-dismissals-cleared");
button.click(); button.click();
Assert.equal( await clearPromise;
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""),
"", Assert.ok(
"Block list is empty clicking Restore button" await QuickSuggest.blockedSuggestions.isEmpty(),
"blockedSuggestions.isEmpty() should return true after restoring dismissals"
);
Assert.ok(
!(await QuickSuggest.canClearDismissedSuggestions()),
"canClearDismissedSuggestions should return false after restoring dismissals"
); );
Assert.ok(button.disabled, "Restore button is disabled after clicking it."); Assert.ok(button.disabled, "Restore button is disabled after clicking it.");
Services.prefs.setBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED, false);
Assert.ok(
!button.disabled,
"Restore button is enabled after disabling weather suggestions."
);
button.click();
Assert.ok(
Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED),
"Weather suggestions are enabled after clicking Restore button"
);
Assert.ok(
button.disabled,
"Restore button is disabled after clicking it again."
);
gBrowser.removeCurrentTab(); gBrowser.removeCurrentTab();
await SpecialPowers.popPrefEnv();
}); });

View file

@ -6,7 +6,6 @@
const lazy = {}; const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ClientID: "resource://gre/modules/ClientID.sys.mjs", ClientID: "resource://gre/modules/ClientID.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
}); });
@ -231,12 +230,10 @@ add_task(async function testReactivateProfileGroupID() {
"upload should be disabled after unchecking checkbox" "upload should be disabled after unchecking checkbox"
); );
// TODO: what could we explicitly await, rather than resorting to a timeout? await TestUtils.waitForCondition(
await new Promise(resolve => lazy.setTimeout(resolve, 1000)); () =>
Services.prefs.getStringPref("toolkit.telemetry.cachedProfileGroupID") ===
Assert.equal( lazy.TelemetryUtils.knownProfileGroupID,
Services.prefs.getStringPref("toolkit.telemetry.cachedProfileGroupID"),
lazy.TelemetryUtils.knownProfileGroupID,
"after disabling data collection, the profile group ID pref should have the canary value" "after disabling data collection, the profile group ID pref should have the canary value"
); );

View file

@ -62,6 +62,9 @@ export var SearchUIUtils = {
case "search-engine-removal": case "search-engine-removal":
this.removalOfSearchEngineNotificationBox(...args); this.removalOfSearchEngineNotificationBox(...args);
break; break;
case "search-settings-reset":
this.searchSettingsResetNotificationBox(...args);
break;
} }
}, },
@ -123,6 +126,45 @@ export var SearchUIUtils = {
} }
}, },
/**
* Infobar informing the user that the search settings had to be reset
* and what their new default engine is.
*
* @param {string} newEngine
* Name of the new default engine.
*/
async searchSettingsResetNotificationBox(newEngine) {
let win = lazy.BrowserWindowTracker.getTopWindow();
let buttons = [
{
"l10n-id": "reset-search-settings-button",
primary: true,
callback() {
const notificationBox = win.gNotificationBox.getNotificationWithValue(
"search-settings-reset"
);
win.gNotificationBox.removeNotification(notificationBox);
},
},
{
supportPage: "prefs-search",
},
];
await win.gNotificationBox.appendNotification(
"search-settings-reset",
{
label: {
"l10n-id": "reset-search-settings-message",
"l10n-args": { newEngine },
},
priority: win.gNotificationBox.PRIORITY_SYSTEM,
},
buttons
);
},
/** /**
* Adds an open search engine and handles error UI. * Adds an open search engine and handles error UI.
* *

View file

@ -36,7 +36,8 @@ serp-categorization:
send_if_empty: false send_if_empty: false
metadata: metadata:
include_info_sections: false include_info_sections: false
use_ohttp: true uploader_capabilities:
- ohttp
bugs: bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1868476 - https://bugzilla.mozilla.org/show_bug.cgi?id=1868476
data_reviews: data_reviews:

View file

@ -27,3 +27,27 @@ add_task(async function test_removalMessage() {
notificationBox.close(); notificationBox.close();
}); });
add_task(async function test_resetMessage() {
Assert.ok(
!gNotificationBox.getNotificationWithValue("search-settings-reset"),
"Message is not displayed initially."
);
BrowserUtils.callModulesFromCategory(
{ categoryName: "search-service-notification" },
"search-settings-reset",
"Engine 1"
);
await TestUtils.waitForCondition(
() => gNotificationBox.getNotificationWithValue("search-settings-reset"),
"Waiting for message to be displayed"
);
let notificationBox = gNotificationBox.getNotificationWithValue(
"search-settings-reset"
);
Assert.ok(notificationBox, "Message is displayed.");
notificationBox.close();
});

View file

@ -24,6 +24,7 @@ const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored";
const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored";
const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored";
const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared"; const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared";
const NOTIFY_LAST_SESSION_RE_ENABLED = "sessionstore-last-session-re-enable";
const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup"; const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup";
const NOTIFY_INITIATING_MANUAL_RESTORE = const NOTIFY_INITIATING_MANUAL_RESTORE =
"sessionstore-initiating-manual-restore"; "sessionstore-initiating-manual-restore";
@ -157,6 +158,7 @@ const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs"; import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
@ -174,6 +176,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
HomePage: "resource:///modules/HomePage.sys.mjs", HomePage: "resource:///modules/HomePage.sys.mjs",
PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs",
sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs",
RunState: "resource:///modules/sessionstore/RunState.sys.mjs", RunState: "resource:///modules/sessionstore/RunState.sys.mjs",
SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs", SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs",
@ -249,6 +252,10 @@ export var SessionStore = {
return SessionStoreInternal.willAutoRestore; return SessionStoreInternal.willAutoRestore;
}, },
get shouldRestoreLastSession() {
return SessionStoreInternal._shouldRestoreLastSession;
},
init: function ss_init() { init: function ss_init() {
SessionStoreInternal.init(); SessionStoreInternal.init();
}, },
@ -889,7 +896,21 @@ export var SessionStore = {
* @returns {MozTabbrowserTabGroup} * @returns {MozTabbrowserTabGroup}
* a reference to the restored tab group in a browser window. * a reference to the restored tab group in a browser window.
*/ */
openSavedTabGroup(tabGroupId, targetWindow) { openSavedTabGroup(
tabGroupId,
targetWindow,
{ source = TabMetrics.METRIC_SOURCE.UNKNOWN } = {}
) {
let isVerticalMode = targetWindow.gBrowser.tabContainer.verticalMode;
Glean.tabgroup.reopen.record({
id: tabGroupId,
source,
layout: isVerticalMode
? TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
: TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
type: TabMetrics.METRIC_REOPEN_TYPE.SAVED,
});
return SessionStoreInternal.openSavedTabGroup(tabGroupId, targetWindow); return SessionStoreInternal.openSavedTabGroup(tabGroupId, targetWindow);
}, },
@ -1008,6 +1029,15 @@ var SessionStoreInternal = {
// whether the last window was closed and should be restored // whether the last window was closed and should be restored
_restoreLastWindow: false, _restoreLastWindow: false,
// whether we should restore last session on the next launch
// of a regular Firefox window. This scenario is triggered
// when a user closes all regular Firefox windows but the session is not over
_shouldRestoreLastSession: false,
// whether we will potentially be restoring the session
// more than once without Firefox restarting in between
_restoreWithoutRestart: false,
// number of tabs currently restoring // number of tabs currently restoring
_tabsRestoringCount: 0, _tabsRestoringCount: 0,
@ -1937,6 +1967,10 @@ var SessionStoreInternal = {
this._windows[aWindow.__SSi].isPopup = true; this._windows[aWindow.__SSi].isPopup = true;
} }
if (aWindow.document.documentElement.hasAttribute("taskbartab")) {
this._windows[aWindow.__SSi].isTaskbarTab = true;
}
let tabbrowser = aWindow.gBrowser; let tabbrowser = aWindow.gBrowser;
// add tab change listeners to all already existing tabs // add tab change listeners to all already existing tabs
@ -1966,6 +2000,10 @@ var SessionStoreInternal = {
*/ */
initializeWindow(aWindow, aInitialState = null) { initializeWindow(aWindow, aInitialState = null) {
let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow);
let isTaskbarTab = this._windows[aWindow.__SSi].isTaskbarTab;
// A regular window is not a private window, taskbar tab window, or popup window
let isRegularWindow =
!isPrivateWindow && !isTaskbarTab && aWindow.toolbar.visible;
// perform additional initialization when the first window is loading // perform additional initialization when the first window is loading
if (lazy.RunState.isStopped) { if (lazy.RunState.isStopped) {
@ -2111,6 +2149,24 @@ var SessionStoreInternal = {
// we actually restored the session just now. // we actually restored the session just now.
this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); this._prefBranch.setBoolPref("sessionstore.resume_session_once", false);
} }
// This is a taskbar-tab specific scenario. If an user closes
// all regular Firefox windows except for taskbar tabs and has
// auto restore on startup enabled, _shouldRestoreLastSession
// will be set to true. We should then restore when a
// regular Firefox window is opened.
else if (
Services.prefs.getBoolPref("browser.taskbarTabs.enabled", false) &&
this._shouldRestoreLastSession &&
isRegularWindow
) {
let lastSessionState = LastSession.getState();
this._globalState.setFromState(lastSessionState);
lazy.SessionCookies.restore(lastSessionState.cookies || []);
this.restoreWindows(aWindow, lastSessionState, {
firstWindow: true,
});
this._shouldRestoreLastSession = false;
}
if (this._restoreLastWindow && aWindow.toolbar.visible) { if (this._restoreLastWindow && aWindow.toolbar.visible) {
// always reset (if not a popup window) // always reset (if not a popup window)
@ -2302,6 +2358,43 @@ var SessionStoreInternal = {
// we explicitly allow saving an "empty" window state. // we explicitly allow saving an "empty" window state.
let isLastWindow = this.isLastRestorableWindow(); let isLastWindow = this.isLastRestorableWindow();
let isLastRegularWindow =
Object.values(this._windows).filter(
wData => !wData.isPrivate && !wData.isTaskbarTab
).length == 1;
let taskbarTabsRemains = Object.values(this._windows).some(
wData => wData.isTaskbarTab
);
// Closing the last regular Firefox window with
// at least one taskbar tab window still active.
// The session is considered over and we need to restore
// the next time a non-private, non-taskbar-tab window
// is opened.
if (
Services.prefs.getBoolPref("browser.taskbarTabs.enabled", false) &&
isLastRegularWindow &&
!winData.isTaskbarTab &&
!winData.isPrivate &&
taskbarTabsRemains
) {
// If the setting is enabled, Firefox should auto-restore
// the next time a regular window is opened
if (this.willAutoRestore) {
this._shouldRestoreLastSession = true;
// Otherwise, we want "restore last session" button
// to be avaliable in the hamburger menu
} else {
Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_RE_ENABLED);
}
let savedState = this.getCurrentState(true);
lazy.PrivacyFilter.filterPrivateWindowsAndTabs(savedState);
LastSession.setState(savedState);
this._restoreWithoutRestart = true;
}
// clear this window from the list, since it has definitely been closed. // clear this window from the list, since it has definitely been closed.
delete this._windows[aWindow.__SSi]; delete this._windows[aWindow.__SSi];
@ -2321,7 +2414,7 @@ var SessionStoreInternal = {
// 2) Flush the window. // 2) Flush the window.
// 3) When the flush is complete, revisit our decision to store the window // 3) When the flush is complete, revisit our decision to store the window
// in _closedWindows, and add/remove as necessary. // in _closedWindows, and add/remove as necessary.
if (!winData.isPrivate) { if (!winData.isPrivate && !winData.isTaskbarTab) {
this.maybeSaveClosedWindow(winData, isLastWindow); this.maybeSaveClosedWindow(winData, isLastWindow);
} }
@ -2342,7 +2435,7 @@ var SessionStoreInternal = {
// Save non-private windows if they have at // Save non-private windows if they have at
// least one saveable tab or are the last window. // least one saveable tab or are the last window.
if (!winData.isPrivate) { if (!winData.isPrivate && !winData.isTaskbarTab) {
this.maybeSaveClosedWindow(winData, isLastWindow); this.maybeSaveClosedWindow(winData, isLastWindow);
if (!isLastWindow && winData.closedId > -1) { if (!isLastWindow && winData.closedId > -1) {
@ -4941,6 +5034,19 @@ var SessionStoreInternal = {
// Restore into windows or open new ones as needed. // Restore into windows or open new ones as needed.
for (let i = 0; i < lastSessionState.windows.length; i++) { for (let i = 0; i < lastSessionState.windows.length; i++) {
let winState = lastSessionState.windows[i]; let winState = lastSessionState.windows[i];
// If we're restoring multiple times without
// Firefox restarting, we need to remove
// the window being restored from "previously closed windows"
if (this._restoreWithoutRestart) {
let restoreIndex = this._closedWindows.findIndex(win => {
return win.closedId == winState.closedId;
});
if (restoreIndex > -1) {
this._closedWindows.splice(restoreIndex, 1);
}
}
let lastSessionWindowID = winState.__lastSessionWindowID; let lastSessionWindowID = winState.__lastSessionWindowID;
// delete lastSessionWindowID so we don't add that to the window again // delete lastSessionWindowID so we don't add that to the window again
delete winState.__lastSessionWindowID; delete winState.__lastSessionWindowID;
@ -4990,6 +5096,10 @@ var SessionStoreInternal = {
this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows)) this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows))
); );
if (this._restoreWithoutRestart) {
this.removeDuplicateClosedWindows(lastSessionState);
}
// Merge closed windows from this session with ones from last session // Merge closed windows from this session with ones from last session
if (lastSessionState._closedWindows) { if (lastSessionState._closedWindows) {
// reset window closedIds and any references to them from closed tabs // reset window closedIds and any references to them from closed tabs
@ -5033,6 +5143,26 @@ var SessionStoreInternal = {
this._notifyOfClosedObjectsChange(); this._notifyOfClosedObjectsChange();
}, },
/**
* There might be duplicates in these two arrays if we
* restore multiple times without restarting in between.
* We will keep the contents of the more recent _closedWindows array
*
* @param lastSessionState
* An object containing information about the previous browsing session
*/
removeDuplicateClosedWindows(lastSessionState) {
// A set of closedIDs for the most recent list of closed windows
let currentClosedIds = new Set(
this._closedWindows.map(window => window.closedId)
);
// Remove closed windows that are present in both current and last session
lastSessionState._closedWindows = lastSessionState._closedWindows.filter(
win => !currentClosedIds.has(win.closedId)
);
},
/** /**
* Revive a crashed tab and restore its state from before it crashed. * Revive a crashed tab and restore its state from before it crashed.
* *
@ -5264,7 +5394,7 @@ var SessionStoreInternal = {
// collect the data for all windows // collect the data for all windows
for (ix in this._windows) { for (ix in this._windows) {
if (this._windows[ix]._restoring) { if (this._windows[ix]._restoring || this._windows[ix].isTaskbarTab) {
// window data is still in _statesToRestore // window data is still in _statesToRestore
continue; continue;
} }
@ -6946,6 +7076,9 @@ var SessionStoreInternal = {
* @returns {boolean} true if the group is saveable. * @returns {boolean} true if the group is saveable.
*/ */
shouldSaveTabGroup: function ssi_shouldSaveTabGroup(group) { shouldSaveTabGroup: function ssi_shouldSaveTabGroup(group) {
if (!group) {
return false;
}
for (let tab of group.tabs) { for (let tab of group.tabs) {
let tabState = lazy.TabState.collect(tab); let tabState = lazy.TabState.collect(tab);
if (this._shouldSaveTabState(tabState)) { if (this._shouldSaveTabState(tabState)) {

View file

@ -109,11 +109,9 @@ export var StartupPerformance = {
delta delta
); );
} else { } else {
Services.telemetry Glean.sessionRestore.manualRestoreDurationUntilEagerTabsRestored.accumulateSingleSample(
.getHistogramById( delta
"FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS" );
)
.add(delta);
} }
Glean.sessionRestore.numberOfEagerTabsRestored.accumulateSingleSample( Glean.sessionRestore.numberOfEagerTabsRestored.accumulateSingleSample(
this._totalNumberOfEagerTabs this._totalNumberOfEagerTabs

View file

@ -27,3 +27,9 @@ skip-if = [
"os == 'win' && os_version == '11.26100' && processor == 'x86'", # Bug 1727691 "os == 'win' && os_version == '11.26100' && processor == 'x86'", # Bug 1727691
"os == 'win' && os_version == '11.26100' && processor == 'x86_64'", # Bug 1727691 "os == 'win' && os_version == '11.26100' && processor == 'x86_64'", # Bug 1727691
] ]
["test_taskbartab_restore.py"]
run-if = ["os == 'win'"]
["test_taskbartab_sessionstate.py"]
run-if = ["os == 'win'"]

View file

@ -43,6 +43,7 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
no_auto_updates=True, no_auto_updates=True,
win_register_restart=False, win_register_restart=False,
test_windows=DEFAULT_WINDOWS, test_windows=DEFAULT_WINDOWS,
taskbartabs_enable=False,
): ):
super(SessionStoreTestCase, self).setUp() super(SessionStoreTestCase, self).setUp()
self.marionette.set_context("chrome") self.marionette.set_context("chrome")
@ -79,6 +80,8 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
"browser.sessionstore.debug.no_auto_updates": no_auto_updates, "browser.sessionstore.debug.no_auto_updates": no_auto_updates,
# Whether to enable the register application restart mechanism. # Whether to enable the register application restart mechanism.
"toolkit.winRegisterApplicationRestart": win_register_restart, "toolkit.winRegisterApplicationRestart": win_register_restart,
# Whether to enable taskbar tabs for this test
"browser.taskbarTabs.enabled": taskbartabs_enable,
} }
) )
@ -138,6 +141,47 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
self.marionette.switch_to_window(win) self.marionette.switch_to_window(win)
self.open_tabs(win, urls) self.open_tabs(win, urls)
# Open a Firefox web app (taskbar tab) window
def open_taskbartab_window(self):
self.marionette.execute_async_script(
"""
let [resolve] = arguments;
(async () => {
let extraOptions = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
Ci.nsIWritablePropertyBag2
);
extraOptions.setPropertyAsBool("taskbartab", true);
let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
args.appendElement(null);
args.appendElement(extraOptions);
args.appendElement(null);
// Simulate opening a taskbar tab window
let win = Services.ww.openWindow(
null,
AppConstants.BROWSER_CHROME_URL,
"_blank",
"chrome,dialog=no,titlebar,close,toolbar,location,personalbar=no,status,menubar=no,resizable,minimizable,scrollbars",
args
);
await new Promise(resolve => {
win.addEventListener("load", resolve, { once: true });
});
await win.delayedStartupPromise;
})().then(resolve);
"""
)
# Helper function for taskbar tabs tests, opens a taskbar tab window,
# closes the regular window, and reopens another regular window.
# Firefox will then be in a "ready to restore" state
def setup_taskbartab_restore_scenario(self):
self.open_taskbartab_window()
taskbar_tab_window_handle = self.marionette.close_chrome_window()[0]
self.marionette.switch_to_window(taskbar_tab_window_handle)
self.marionette.open(type="window")
def open_tabs(self, win, urls): def open_tabs(self, win, urls):
"""Open a set of URLs inside a window in new tabs. """Open a set of URLs inside a window in new tabs.

View file

@ -0,0 +1,134 @@
# 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/.
import os
import sys
from marionette_driver import Wait
# add this directory to the path
sys.path.append(os.path.dirname(__file__))
from session_store_test_case import SessionStoreTestCase
def inline(title):
return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format(
title
)
class TestManualRestoreWithTaskbarTabs(SessionStoreTestCase):
def setUp(self):
super(TestManualRestoreWithTaskbarTabs, self).setUp(
startup_page=1,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all regular windows except for a taskbar tab window. The
session should be over at this point. Opening another regular Firefox
window will open "restore previous session" in the hamburger menu.
And clicking it will restore the correct session.
"""
def test_restore_without_closing_taskbartab(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
# See session_store_test_case.py
self.setup_taskbartab_restore_scenario()
# Verify that "restore previous session" button
# is visible in the hamburger menu
self.assertEqual(
self.marionette.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return PanelMultiView.getViewNode(
newWindow.document,
"appMenu-restoreSession"
).hasAttribute("disabled");
"""
),
False,
"The restore last session button should be visible",
)
# Simulate clicking "restore previous session"
self.marionette.execute_script(
"""
SessionStore.restoreLastSession();
"""
)
# Wait for the restore to be completed,
# meaning the window we opened should have
# two tabs again.
Wait(self.marionette).until(
lambda mn: mn.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return newWindow.gBrowser.tabs.length;
"""
)
== 2
)
class TestAutoRestoreWithTaskbarTabs(SessionStoreTestCase):
def setUp(self):
super(TestAutoRestoreWithTaskbarTabs, self).setUp(
startup_page=3,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all regular windows except for a taskbar tab window. The
session should be over at this point. Opening another regular Firefox
window will open automatically restore the correct session
"""
def test_restore_without_closing_taskbartab(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
self.setup_taskbartab_restore_scenario()
# Wait for the auto restore to be completed,
# meaning the window we opened should have
# the original two tabs plus the home page tab.
Wait(self.marionette).until(
lambda mn: mn.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return newWindow.gBrowser.tabs.length;
"""
)
== 3
)

View file

@ -0,0 +1,91 @@
# 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/.
import os
import sys
# add this directory to the path
sys.path.append(os.path.dirname(__file__))
from session_store_test_case import SessionStoreTestCase
def inline(title):
return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format(
title
)
class TestTaskbarTabSessionState(SessionStoreTestCase):
def setUp(self):
super(TestTaskbarTabSessionState, self).setUp(
startup_page=1,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all Firefox windows with the web app being closed last,
the session store state should include the last regular window
that's closed, but not the web app
"""
def test_taskbartab_session_state(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
self.marionette.enforce_gecko_prefs({"browser.taskbarTabs.enabled": True})
self.open_taskbartab_window()
# Close the original regular Firefox window
taskbar_tab_window_handle = self.marionette.close_chrome_window()[0]
self.marionette.switch_to_window(taskbar_tab_window_handle)
self.marionette.set_context("content")
dummy_html = self.marionette.absolute_url("empty.html")
self.marionette.navigate(dummy_html)
self.marionette.set_context("chrome")
self.marionette.quit()
self.marionette.start_session()
self.marionette.set_context("chrome")
# check for the session store state, it should have only two tabs and one window
self.assertEqual(
self.marionette.execute_script(
"""
const { _LastSession } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/SessionStore.sys.mjs"
);
return _LastSession.getState().windows.length
"""
),
1,
"One window should be in the session state",
)
self.assertEqual(
self.marionette.execute_script(
"""
const { _LastSession } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/SessionStore.sys.mjs"
);
return _LastSession.getState().windows[0].tabs.length
"""
),
2,
"Two tabs should be in the session state",
)

View file

@ -1753,7 +1753,7 @@ var SidebarController = {
* hiding of the sidebar. * hiding of the sidebar.
* @param {boolean} options.dismissPanel -Only close the panel or close the whole sidebar (the default.) * @param {boolean} options.dismissPanel -Only close the panel or close the whole sidebar (the default.)
*/ */
hide({ triggerNode, dismissPanel = true } = {}) { hide({ triggerNode, dismissPanel = this.sidebarRevampEnabled } = {}) {
if (!this.isOpen) { if (!this.isOpen) {
return; return;
} }
@ -1910,6 +1910,14 @@ var SidebarController = {
// Re-render sidebar-main so that templating is updated // Re-render sidebar-main so that templating is updated
// for proper keyboard navigation for Tools // for proper keyboard navigation for Tools
this.sidebarMain.requestUpdate(); this.sidebarMain.requestUpdate();
if (
!this.verticalTabsEnabled &&
this.sidebarRevampVisibility == "hide-sidebar"
) {
// the sidebar.visibility pref didn't change so updateVisbility hasn't
// been called; we need to call it here to un-expand the launcher
this._state.updateVisibility(undefined, false);
}
}, },
debouncedMouseEnter() { debouncedMouseEnter() {

View file

@ -15,3 +15,8 @@ fxview-search-textbox {
.menu-button::part(button) { .menu-button::part(button) {
margin-inline-start: var(--space-small); margin-inline-start: var(--space-small);
} }
.nested-card {
--card-accordion-closed-icon: url("chrome://global/skin/icons/arrow-right.svg");
--card-accordion-open-icon: url("chrome://global/skin/icons/arrow-down.svg");
}

View file

@ -45,6 +45,12 @@ export class SidebarHistory extends SidebarPage {
this._menu = doc.getElementById("sidebar-history-menu"); this._menu = doc.getElementById("sidebar-history-menu");
this._menuSortByDate = doc.getElementById("sidebar-history-sort-by-date"); this._menuSortByDate = doc.getElementById("sidebar-history-sort-by-date");
this._menuSortBySite = doc.getElementById("sidebar-history-sort-by-site"); this._menuSortBySite = doc.getElementById("sidebar-history-sort-by-site");
this._menuSortByDateSite = doc.getElementById(
"sidebar-history-sort-by-date-and-site"
);
this._menuSortByLastVisited = doc.getElementById(
"sidebar-history-sort-by-last-visited"
);
this._menu.addEventListener("command", this); this._menu.addEventListener("command", this);
this._menu.addEventListener("popuphidden", this.handlePopupEvent); this._menu.addEventListener("popuphidden", this.handlePopupEvent);
this.addContextMenuListeners(); this.addContextMenuListeners();
@ -75,6 +81,12 @@ export class SidebarHistory extends SidebarPage {
case "sidebar-history-sort-by-site": case "sidebar-history-sort-by-site":
this.controller.onChangeSortOption(e, "site"); this.controller.onChangeSortOption(e, "site");
break; break;
case "sidebar-history-sort-by-date-and-site":
this.controller.onChangeSortOption(e, "datesite");
break;
case "sidebar-history-sort-by-last-visited":
this.controller.onChangeSortOption(e, "lastvisited");
break;
case "sidebar-history-clear": case "sidebar-history-clear":
lazy.Sanitizer.showUI(this.topWindow); lazy.Sanitizer.showUI(this.topWindow);
break; break;
@ -161,39 +173,63 @@ export class SidebarHistory extends SidebarPage {
const { historyVisits } = this.controller; const { historyVisits } = this.controller;
switch (this.controller.sortOption) { switch (this.controller.sortOption) {
case "date": case "date":
return historyVisits.map(({ l10nId, items }, i) => { return historyVisits.map(({ l10nId, items }, i) =>
let tabIndex = i > 0 ? "-1" : undefined; this.#dateCardTemplate(l10nId, i, items)
return html` <moz-card );
type="accordion"
?expanded=${i < DAYS_EXPANDED_INITIALLY}
data-l10n-id=${l10nId}
data-l10n-args=${JSON.stringify({
date: items[0].time,
})}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
});
case "site": case "site":
return historyVisits.map(({ domain, items }, i) => { return historyVisits.map(({ domain, items }, i) =>
let tabIndex = i > 0 ? "-1" : undefined; this.#siteCardTemplate(domain, i, items)
return html` <moz-card );
type="accordion" case "datesite":
expanded return historyVisits.map(({ l10nId, items }, i) =>
heading=${domain} this.#dateCardTemplate(l10nId, i, items, true)
@keydown=${this.handleCardKeydown} );
tabindex=${ifDefined(tabIndex)} case "lastvisited":
> return historyVisits.map(
${this.#tabListTemplate(this.getTabItems(items))} ({ items }) =>
</moz-card>`; html`<moz-card>
}); ${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`
);
default: default:
return []; return [];
} }
} }
#dateCardTemplate(l10nId, index, items, isDateSite = false) {
const tabIndex = index > 0 ? "-1" : undefined;
return html` <moz-card
type="accordion"
?expanded=${index < DAYS_EXPANDED_INITIALLY}
data-l10n-id=${l10nId}
data-l10n-args=${JSON.stringify({
date: isDateSite ? items[0][1][0].time : items[0].time,
})}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${isDateSite
? items.map(([domain, visits], i) =>
this.#siteCardTemplate(domain, i, visits, true)
)
: this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
}
#siteCardTemplate(domain, index, items, isDateSite = false) {
let tabIndex = index > 0 ? "-1" : undefined;
return html` <moz-card
class=${isDateSite ? "nested-card" : ""}
type="accordion"
?expanded=${!isDateSite}
heading=${domain}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
}
#emptyMessageTemplate() { #emptyMessageTemplate() {
let descriptionHeader; let descriptionHeader;
let descriptionLabels; let descriptionLabels;
@ -302,6 +338,14 @@ export class SidebarHistory extends SidebarPage {
"checked", "checked",
this.controller.sortOption == "site" this.controller.sortOption == "site"
); );
this._menuSortByDateSite.setAttribute(
"checked",
this.controller.sortOption == "datesite"
);
this._menuSortByLastVisited.setAttribute(
"checked",
this.controller.sortOption == "lastvisited"
);
} }
render() { render() {

View file

@ -36,7 +36,7 @@ export class SidebarPanelHeader extends MozLitElement {
view=${this.view} view=${this.view}
size="default" size="default"
type="icon ghost" type="icon ghost"
tabindex="-1" tabindex="1"
> >
</moz-button> </moz-button>
</div> </div>

View file

@ -30,7 +30,7 @@ function isActiveElement(el) {
} }
add_task(async function test_keyboard_navigation() { add_task(async function test_keyboard_navigation() {
const { document } = win; const { document, SidebarController } = win;
const sidebar = document.querySelector("sidebar-main"); const sidebar = document.querySelector("sidebar-main");
info("Waiting for tool buttons to be present"); info("Waiting for tool buttons to be present");
await BrowserTestUtils.waitForMutationCondition( await BrowserTestUtils.waitForMutationCondition(
@ -87,7 +87,38 @@ add_task(async function test_keyboard_navigation() {
info("Press Tab key."); info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win); EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(isActiveElement(customizeButton), "Customize button is focused."); ok(isActiveElement(customizeButton), "Customize button is focused.");
}).skip(); // Bug 1950504 info("Press Enter key again.");
const promiseFocused = BrowserTestUtils.waitForEvent(win, "SidebarFocused");
EventUtils.synthesizeKey("KEY_Enter", {}, win);
await promiseFocused;
await sidebar.updateComplete;
ok(sidebar.open, "Sidebar is open.");
let customizeDocument = SidebarController.browser.contentDocument;
const customizeComponent =
customizeDocument.querySelector("sidebar-customize");
const sidebarPanelHeader = customizeComponent.shadowRoot.querySelector(
"sidebar-panel-header"
);
let closeButton = sidebarPanelHeader.closeButton;
info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(isActiveElement(closeButton), "Close button is focused.");
info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(
isActiveElement(customizeComponent.verticalTabsInput),
"First customize component is focused"
);
info("Press Tab and Shift key.");
EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, win);
ok(isActiveElement(closeButton), "Close button is focused.");
EventUtils.synthesizeKey("KEY_Enter", {}, win);
await sidebar.updateComplete;
ok(!sidebar.open, "Sidebar is closed.");
});
add_task(async function test_menu_items_labeled() { add_task(async function test_menu_items_labeled() {
const { document, SidebarController } = win; const { document, SidebarController } = win;

View file

@ -188,6 +188,8 @@ add_task(async function test_history_sort() {
const menu = component._menu; const menu = component._menu;
const sortByDateButton = component._menuSortByDate; const sortByDateButton = component._menuSortByDate;
const sortBySiteButton = component._menuSortBySite; const sortBySiteButton = component._menuSortBySite;
const sortByDateSiteButton = component._menuSortByDateSite;
const sortByLastVisitedButton = component._menuSortByLastVisited;
info("Sort history by site."); info("Sort history by site.");
let promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); let promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
@ -233,6 +235,58 @@ add_task(async function test_history_sort() {
"The cards for Today and Yesterday are expanded." "The cards for Today and Yesterday are expanded."
); );
} }
info("Sort history by date and site.");
promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
EventUtils.synthesizeMouseAtCenter(menuButton, {}, contentWindow);
await promiseMenuShown;
menu.activateItem(sortByDateSiteButton);
await BrowserTestUtils.waitForMutationCondition(
component.shadowRoot,
{ childList: true, subtree: true },
() => component.lists.length === dates.length * URLs.length
);
Assert.ok(
true,
"There is a card for each date, and a nested card for each site."
);
Assert.equal(
sortByDateSiteButton.getAttribute("checked"),
"true",
"Sort by date and site is checked."
);
const outerCards = [...component.cards].filter(
el => !el.classList.contains("nested-card")
);
for (const [i, card] of outerCards.entries()) {
Assert.equal(
card.expanded,
i === 0 || i === 1,
"The cards for Today and Yesterday are expanded."
);
}
info("Sort history by last visited.");
promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
EventUtils.synthesizeMouseAtCenter(menuButton, {}, contentWindow);
await promiseMenuShown;
menu.activateItem(sortByLastVisitedButton);
await BrowserTestUtils.waitForMutationCondition(
component.shadowRoot,
{ childList: true, subtree: true },
() => component.lists.length === 1
);
Assert.equal(
component.lists[0].tabItems.length,
URLs.length,
"There is a single card with a row for each site."
);
Assert.equal(
sortByLastVisitedButton.getAttribute("checked"),
"true",
"Sort by last visited is checked."
);
win.SidebarController.hide(); win.SidebarController.hide();
}); });

View file

@ -585,3 +585,57 @@ add_task(async function test_vertical_tabs_min_width() {
} }
await SpecialPowers.popPrefEnv(); await SpecialPowers.popPrefEnv();
}); });
add_task(
async function test_launcher_collapsed_entering_horiz_tabs_with_hide_sidebar() {
const { sidebarMain } = SidebarController;
await SpecialPowers.pushPrefEnv({ set: [["sidebar.verticalTabs", true]] });
await waitForTabstripOrientation("vertical");
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main is shown initially."
);
ok(
sidebarMain.expanded,
"Launcher is expanded with vertical tabs and always-show"
);
await SpecialPowers.pushPrefEnv({
set: [["sidebar.visibility", "hide-sidebar"]],
});
await sidebarMain.updateComplete;
ok(
BrowserTestUtils.isHidden(sidebarMain),
"Revamped sidebar main hidden when we switch to hide-sidebar."
);
// toggle the launcher back open.
document.getElementById("sidebar-button").doCommand();
await sidebarMain.updateComplete;
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main visible again."
);
ok(
sidebarMain.expanded,
"Launcher is still expanded as vertical tabs are still enabled"
);
// switch back to horizontal tabs and confirm the launcher get un-expanded
await SpecialPowers.pushPrefEnv({ set: [["sidebar.verticalTabs", false]] });
await waitForTabstripOrientation("horizontal");
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main is still visible when we switch to horizontal tabs."
);
ok(
!sidebarMain.expanded,
"Launcher is collapsed when we switch to horizontal tabs with hide-sidebar"
);
await SpecialPowers.popPrefEnv();
await SpecialPowers.popPrefEnv();
await SpecialPowers.popPrefEnv();
}
);

View file

@ -17,16 +17,16 @@
} }
}, },
"duty-start-dates": { "duty-start-dates": {
"2025-24-03": "Nikki Sharpley", "2025-04-10": "Nikki Sharpley",
"2025-01-06": "Kelly Cochrane", "2025-06-01": "Kelly Cochrane",
"2025-01-08": "Jonathan Sudiaman", "2025-08-01": "Jonathan Sudiaman",
"2025-01-10": "Sam Foster", "2025-10-01": "Sam Foster",
"2025-01-12": "Sarah Clements", "2025-12-01": "Sarah Clements",
"2026-01-02": "Nikki Sharpley", "2026-02-01": "Nikki Sharpley",
"2026-01-04": "Kelly Cochrane", "2026-04-01": "Kelly Cochrane",
"2026-01-06": "Jonathan Sudiaman", "2026-06-01": "Jonathan Sudiaman",
"2026-01-08": "Sam Foster", "2026-08-01": "Sam Foster",
"2026-01-10": "Sarah Clements", "2026-10-01": "Sarah Clements",
"2026-01-12": "Nikki Sharpley" "2026-12-01": "Nikki Sharpley"
} }
} }

View file

@ -2,9 +2,11 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # 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/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import subprocess import subprocess
import threading import threading
import time import time
from pathlib import Path
import mozpack.path as mozpath import mozpack.path as mozpath
from mach.decorators import Command, CommandArgument, SubCommand from mach.decorators import Command, CommandArgument, SubCommand
@ -40,6 +42,19 @@ def storybook_build(command_context):
return run_npm(command_context, args=["run", "build-storybook"]) return run_npm(command_context, args=["run", "build-storybook"])
@SubCommand(
"storybook",
"upgrade",
description="Upgrade all storybook dependencies to latest of ranges in package.json",
)
def storybook_upgrade(command_context):
delete_storybook_node_modules()
package_lock_path = "browser/components/storybook/package-lock.json"
if os.path.exists(package_lock_path):
os.unlink(package_lock_path)
return run_npm(command_context, args=["install"])
@SubCommand( @SubCommand(
"storybook", "launch", description="Launch the Storybook site in your local build." "storybook", "launch", description="Launch the Storybook site in your local build."
) )
@ -55,6 +70,21 @@ def storybook_launch(command_context):
) )
def delete_path(path):
if path.is_file() or path.is_symlink():
path.unlink()
return
for p in path.iterdir():
delete_path(p)
path.rmdir()
def delete_storybook_node_modules():
node_modules_path = Path("browser/components/storybook/node_modules")
if node_modules_path.exists():
delete_path(node_modules_path)
def start_browser(command_context): def start_browser(command_context):
# This delay is used to avoid launching the browser before the Storybook server has started. # This delay is used to avoid launching the browser before the Storybook server has started.
time.sleep(5) time.sleep(5)

View file

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
const MAX_INITIAL_ITEMS = 5; const MAX_INITIAL_ITEMS = 5;
@ -77,7 +78,9 @@ export class GroupsPanel {
} }
case "allTabsGroupView_restoreGroup": case "allTabsGroupView_restoreGroup":
this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win); this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win, {
source: TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU,
});
break; break;
} }
} }

View file

@ -143,23 +143,15 @@ export class SmartTabGroupingManager {
async smartTabGroupingForGroup(group, tabs) { async smartTabGroupingForGroup(group, tabs) {
// Add tabs to suggested group // Add tabs to suggested group
const groupTabs = group.tabs; const groupTabs = group.tabs;
const uniqueSpecs = new Set();
const allTabs = tabs.filter(tab => { const allTabs = tabs.filter(tab => {
// Don't include tabs already pinned // Don't include tabs already pinned
if (tab.pinned) { if (tab.pinned) {
return false; return false;
} }
if (!tab?.linkedBrowser?.currentURI?.spec) {
const spec = tab?.linkedBrowser?.currentURI?.spec;
if (!spec) {
return false; return false;
} }
if (!uniqueSpecs.has(spec)) { return true;
uniqueSpecs.add(spec);
return true;
}
return false;
}); });
// find tabs that are part of the group // find tabs that are part of the group

View file

@ -1,18 +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/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tab groups could originate. These "source" values
* should be sent as extra data with tab group-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
UNKNOWN: "unknown",
});
export const TabGroupMetrics = {
METRIC_SOURCE,
};

View file

@ -0,0 +1,62 @@
/* 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/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tabs could originate. These "source" values
* should be sent as extra data with tab-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
TAB_MENU: "tab_menu",
DRAG_AND_DROP: "drag",
SUGGEST: "suggest",
RECENT_TABS: "recent",
UNKNOWN: "unknown",
});
const METRIC_TABS_LAYOUT = Object.freeze({
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
});
const METRIC_REOPEN_TYPE = Object.freeze({
SAVED: "saved",
DELETED: "deleted",
});
/**
* @typedef {object} TabMetricsContext
* @property {boolean} [isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone). This causes telemetry events to fire.
* @property {string} [telemetrySource="unknown"]
* The system, surface, or control the user used to take this action.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
/**
* Creates a `TabMetricsContext` object for a user event originating from
* the specified source.
*
* @param {string} telemetrySource
* @see TabMetrics.METRIC_SOURCE
* @returns {TabMetricsContext}
*/
function userTriggeredContext(telemetrySource) {
return {
isUserTriggered: true,
telemetrySource,
};
}
export const TabMetrics = {
METRIC_SOURCE,
METRIC_TABS_LAYOUT,
METRIC_REOPEN_TYPE,
userTriggeredContext,
};

View file

@ -235,6 +235,9 @@ class TabsListBase {
} }
} }
/**
* @param {MozTabbrowserTab} tab
*/
_moveTab(tab) { _moveTab(tab) {
let item = this.tabToElement.get(tab); let item = this.tabToElement.get(tab);
if (item) { if (item) {

View file

@ -110,8 +110,8 @@
PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs", PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
SmartTabGroupingManager: SmartTabGroupingManager:
"moz-src:///browser/components/tabbrowser/SmartTabGrouping.sys.mjs", "moz-src:///browser/components/tabbrowser/SmartTabGrouping.sys.mjs",
TabGroupMetrics: TabMetrics:
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs", "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
TabStateFlusher: TabStateFlusher:
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs", "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
UrlbarProviderOpenTabs: UrlbarProviderOpenTabs:
@ -2958,7 +2958,7 @@
* Causes the group create UI to be displayed and telemetry events to be fired. * Causes the group create UI to be displayed and telemetry events to be fired.
* @param {string} [options.telemetryUserCreateSource] * @param {string} [options.telemetryUserCreateSource]
* The means by which the tab group was created. * The means by which the tab group was created.
* @see TabGroupMetrics.METRIC_SOURCE for possible values. * @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown". * Defaults to "unknown".
*/ */
addTabGroup( addTabGroup(
@ -2981,6 +2981,10 @@
} }
if (!id) { if (!id) {
// Note: If this changes, make sure to also update the
// getExtTabGroupIdForInternalTabGroupId implementation in
// browser/components/extensions/parent/ext-browser.js.
// See: Bug 1960104 - Improve tab group ID generation in addTabGroup
id = `${Date.now()}-${Math.round(Math.random() * 100)}`; id = `${Date.now()}-${Math.round(Math.random() * 100)}`;
} }
let group = this._createTabGroup(id, color, false, label); let group = this._createTabGroup(id, color, false, label);
@ -3035,14 +3039,14 @@
* switches windows). This causes telemetry events to fire. * switches windows). This causes telemetry events to fire.
* @param {string} [options.telemetrySource="unknown"] * @param {string} [options.telemetrySource="unknown"]
* The means by which the tab group was removed. * The means by which the tab group was removed.
* @see TabGroupMetrics.METRIC_SOURCE for possible values. * @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown". * Defaults to "unknown".
*/ */
async removeTabGroup( async removeTabGroup(
group, group,
options = { options = {
isUserTriggered: false, isUserTriggered: false,
telemetrySource: this.TabGroupMetrics.METRIC_SOURCE.UNKNOWN, telemetrySource: this.TabMetrics.METRIC_SOURCE.UNKNOWN,
} }
) { ) {
if (this.tabGroupMenu.panel.state != "closed") { if (this.tabGroupMenu.panel.state != "closed") {
@ -3943,11 +3947,11 @@
// Place tab at the end of the contextual tab group because one of: // Place tab at the end of the contextual tab group because one of:
// 1) no `itemAfter` so `tab` should be the last tab in the tab strip // 1) no `itemAfter` so `tab` should be the last tab in the tab strip
// 2) `itemAfter` is in a different tab group // 2) `itemAfter` is in a different tab group
this.moveTabToGroup(tab, tabGroup); tabGroup.appendChild(tab);
} }
} else if ( } else if (
this.isTab(itemAfter) && (this.isTab(itemAfter) && itemAfter.group?.tabs[0] == itemAfter) ||
itemAfter?.group?.tabs[0] == itemAfter this.isTabGroupLabel(itemAfter)
) { ) {
// If there is ambiguity around whether or not a tab should be inserted // If there is ambiguity around whether or not a tab should be inserted
// into a group (i.e. because the new tab is being inserted on the // into a group (i.e. because the new tab is being inserted on the
@ -5869,7 +5873,7 @@
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element * @param {Element} element
* @returns {boolean} * @returns {boolean}
* `true` if element is a `<tab>` * `true` if element is a `<tab>`
*/ */
@ -5878,7 +5882,16 @@
} }
/** /**
* @param {MozTabbrowserTab|MozTextLabel} element * @param {Element} element
* @returns {boolean}
* `true` if element is a `<tab-group>`
*/
isTabGroup(element) {
return !!(element?.tagName == "tab-group");
}
/**
* @param {Element} element
* @returns {boolean} * @returns {boolean}
* `true` if element is the `<label>` in a `<tab-group>` * `true` if element is the `<label>` in a `<tab-group>`
*/ */
@ -5911,7 +5924,7 @@
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aTab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* The tab or tab group to move. Also accepts a tab group label as a * The tab or tab group to move. Also accepts a tab group label as a
* stand-in for its group. * stand-in for its group.
* @param {object} [options] * @param {object} [options]
@ -5921,18 +5934,36 @@
* The desired position, expressed as the index within the * The desired position, expressed as the index within the
* `MozTabbrowserTabs::ariaFocusableItems` array. * `MozTabbrowserTabs::ariaFocusableItems` array.
* @param {boolean} [options.forceUngrouped=false] * @param {boolean} [options.forceUngrouped=false]
* Force `aTab` to move into position as a standalone tab, overriding * Force `element` to move into position as a standalone tab, overriding
* any possibility of entering a tab group. For example, setting `true` * any possibility of entering a tab group. For example, setting `true`
* ensures that a pinned tab will not accidentally be placed inside of * ensures that a pinned tab will not accidentally be placed inside of
* a tab group, since pinned tabs are presently not allowed in tab groups. * a tab group, since pinned tabs are presently not allowed in tab groups.
* @property {boolean} [options.isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone) to move the tab. This causes telemetry
* events to fire.
* @property {string} [options.telemetrySource="unknown"]
* The system, surface, or control the user used to move the tab.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/ */
moveTabTo(aTab, { elementIndex, tabIndex, forceUngrouped = false } = {}) { moveTabTo(
element,
{
elementIndex,
tabIndex,
forceUngrouped = false,
isUserTriggered = false,
telemetrySource = this.TabMetrics.METRIC_SOURCE.UNKNOWN,
} = {}
) {
if (typeof elementIndex == "number") { if (typeof elementIndex == "number") {
tabIndex = this.#elementIndexToTabIndex(elementIndex); tabIndex = this.#elementIndexToTabIndex(elementIndex);
} }
// Don't allow mixing pinned and unpinned tabs. // Don't allow mixing pinned and unpinned tabs.
if (this.isTab(aTab) && aTab.pinned) { if (this.isTab(element) && element.pinned) {
tabIndex = Math.min(tabIndex, this.pinnedTabCount - 1); tabIndex = Math.min(tabIndex, this.pinnedTabCount - 1);
} else { } else {
tabIndex = Math.max(tabIndex, this.pinnedTabCount); tabIndex = Math.max(tabIndex, this.pinnedTabCount);
@ -5940,73 +5971,84 @@
// Return early if the tab is already in the right spot. // Return early if the tab is already in the right spot.
if ( if (
this.isTab(aTab) && this.isTab(element) &&
aTab._tPos == tabIndex && element._tPos == tabIndex &&
!(aTab.group && forceUngrouped) !(element.group && forceUngrouped)
) { ) {
return; return;
} }
// When asked to move a tab group label, we need to move the whole group // When asked to move a tab group label, we need to move the whole group
// instead. // instead.
if (this.isTabGroupLabel(aTab)) { if (this.isTabGroupLabel(element)) {
element = element.group;
}
if (this.isTabGroup(element)) {
forceUngrouped = true; forceUngrouped = true;
aTab = aTab.group;
} }
this.#handleTabMove(aTab, () => { this.#handleTabMove(
let neighbor = this.tabs[tabIndex]; element,
if (forceUngrouped && neighbor.group) { () => {
neighbor = neighbor.group; let neighbor = this.tabs[tabIndex];
} if (forceUngrouped && neighbor.group) {
if (neighbor && tabIndex > aTab._tPos) { neighbor = neighbor.group;
neighbor.after(aTab); }
} else { if (neighbor && this.isTab(element) && tabIndex > element._tPos) {
this.tabContainer.insertBefore(aTab, neighbor); neighbor.after(element);
} } else {
}); this.tabContainer.insertBefore(element, neighbor);
}
},
{ isUserTriggered, telemetrySource }
);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabBefore(tab, targetElement) { moveTabBefore(element, targetElement, metricsContext) {
this.#moveTabNextTo(tab, targetElement, true); this.#moveTabNextTo(element, targetElement, true, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs * @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabsBefore(tabs, targetElement) { moveTabsBefore(elements, targetElement, metricsContext) {
this.#moveTabsNextTo(tabs, targetElement, true); this.#moveTabsNextTo(elements, targetElement, true, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabAfter(tab, targetElement) { moveTabAfter(element, targetElement, metricsContext) {
this.#moveTabNextTo(tab, targetElement, false); this.#moveTabNextTo(element, targetElement, false, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs * @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/ */
moveTabsAfter(tabs, targetElement) { moveTabsAfter(elements, targetElement, metricsContext) {
this.#moveTabsNextTo(tabs, targetElement, false); this.#moveTabsNextTo(elements, targetElement, false, metricsContext);
} }
/** /**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab * @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* The tab or tab group to move. Also accepts a tab group label as a * The tab or tab group to move. Also accepts a tab group label as a
* stand-in for its group. * stand-in for its group.
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore * @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/ */
#moveTabNextTo(tab, targetElement, moveBefore = false) { #moveTabNextTo(element, targetElement, moveBefore = false, metricsContext) {
if (this.isTabGroupLabel(targetElement)) { if (this.isTabGroupLabel(targetElement)) {
targetElement = targetElement.group; targetElement = targetElement.group;
if (!moveBefore) { if (!moveBefore) {
@ -6014,53 +6056,82 @@
moveBefore = true; moveBefore = true;
} }
} }
if (this.isTabGroupLabel(tab)) { if (this.isTabGroupLabel(element)) {
tab = tab.group; element = element.group;
if (targetElement?.group) { if (targetElement?.group) {
targetElement = targetElement.group; targetElement = targetElement.group;
} }
} }
// Don't allow mixing pinned and unpinned tabs. // Don't allow mixing pinned and unpinned tabs.
if (tab.pinned && !targetElement?.pinned) { if (element.pinned && !targetElement?.pinned) {
targetElement = this.tabs[this.pinnedTabCount - 1]; targetElement = this.tabs[this.pinnedTabCount - 1];
moveBefore = false; moveBefore = false;
} else if (!tab.pinned && targetElement && targetElement.pinned) { } else if (!element.pinned && targetElement && targetElement.pinned) {
targetElement = this.tabs[this.pinnedTabCount]; targetElement = this.tabs[this.pinnedTabCount];
moveBefore = true; moveBefore = true;
} }
let getContainer = () => { let getContainer = () => {
if (tab.pinned && this.tabContainer.verticalMode) { if (element.pinned && this.tabContainer.verticalMode) {
return this.tabContainer.verticalPinnedTabsContainer; return this.tabContainer.verticalPinnedTabsContainer;
} }
return this.tabContainer; return this.tabContainer;
}; };
this.#handleTabMove(tab, () => { this.#handleTabMove(
if (moveBefore) { element,
getContainer().insertBefore(tab, targetElement); () => {
} else if (targetElement) { if (moveBefore) {
targetElement.after(tab); getContainer().insertBefore(element, targetElement);
} else { } else if (targetElement) {
getContainer().appendChild(tab); targetElement.after(element);
} } else {
}); getContainer().appendChild(element);
}
},
metricsContext
);
} }
/** /**
* @param {MozTabbrowserTab[]} tabs * @param {MozTabbrowserTab[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement * @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore * @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/ */
#moveTabsNextTo(tabs, targetElement, moveBefore = false) { #moveTabsNextTo(
this.#moveTabNextTo(tabs[0], targetElement, moveBefore); elements,
for (let i = 1; i < tabs.length; i++) { targetElement,
this.#moveTabNextTo(tabs[i], tabs[i - 1]); moveBefore = false,
metricsContext
) {
this.#moveTabNextTo(
elements[0],
targetElement,
moveBefore,
metricsContext
);
for (let i = 1; i < elements.length; i++) {
this.#moveTabNextTo(
elements[i],
elements[i - 1],
false,
metricsContext
);
} }
} }
moveTabToGroup(aTab, aGroup) { /**
*
* @param {MozTabbrowserTab} aTab
* @param {MozTabbrowserTabGroup} aGroup
* @param {TabMetricsContext} [metricsContext]
*/
moveTabToGroup(aTab, aGroup, metricsContext) {
if (!this.isTab(aTab)) {
throw new Error("Can only move a tab into a tab group");
}
if (aTab.pinned) { if (aTab.pinned) {
return; return;
} }
@ -6069,47 +6140,118 @@
} }
aGroup.collapsed = false; aGroup.collapsed = false;
this.#handleTabMove(aTab, () => aGroup.appendChild(aTab)); this.#handleTabMove(aTab, () => aGroup.appendChild(aTab), metricsContext);
this.removeFromMultiSelectedTabs(aTab); this.removeFromMultiSelectedTabs(aTab);
this.tabContainer._notifyBackgroundTab(aTab); this.tabContainer._notifyBackgroundTab(aTab);
} }
/** /**
* @param {MozTabbrowserTab} aTab * @typedef {object} TabMoveState
* @param {function():void} moveActionCallback * @property {number} tabIndex
* @returns * @property {number} [elementIndex]
* @property {string} [tabGroupId]
*/ */
#handleTabMove(aTab, moveActionCallback) {
/**
* @param {MozTabbrowserTab} tab
* @returns {TabMoveState|undefined}
*/
#getTabMoveState(tab) {
if (!this.isTab(tab)) {
return undefined;
}
let state = {
tabIndex: tab._tPos,
};
if (tab.visible) {
state.elementIndex = tab.elementIndex;
}
if (tab.group) {
state.tabGroupId = tab.group.id;
}
return state;
}
/**
* @param {MozTabbrowserTab} tab
* @param {TabMoveState} [previousTabState]
* @param {TabMoveState} [currentTabState]
* @param {TabMetricsContext} [metricsContext]
*/
#notifyOnTabMove(tab, previousTabState, currentTabState, metricsContext) {
if (!this.isTab(tab) || !previousTabState || !currentTabState) {
return;
}
let changedPosition =
previousTabState.tabIndex != currentTabState.tabIndex;
let changedTabGroup =
previousTabState.tabGroupId != currentTabState.tabGroupId;
if (changedPosition || changedTabGroup) {
tab.dispatchEvent(
new CustomEvent("TabMove", {
bubbles: true,
detail: {
previousTabState,
currentTabState,
isUserTriggered: metricsContext?.isUserTriggered ?? false,
telemetrySource:
metricsContext?.telemetrySource ??
this.TabMetrics.METRIC_SOURCE.UNKNOWN,
},
})
);
}
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {function():void} moveActionCallback
* @param {TabMetricsContext} [metricsContext]
*/
#handleTabMove(element, moveActionCallback, metricsContext) {
let tabs;
if (this.isTab(element)) {
tabs = [element];
} else if (this.isTabGroup(element)) {
tabs = element.tabs;
} else {
throw new Error("Can only move a tab or tab group within the tab bar");
}
let wasFocused = document.activeElement == this.selectedTab; let wasFocused = document.activeElement == this.selectedTab;
let oldPosition = this.isTab(aTab) && aTab._tPos; let previousTabStates = tabs.map(tab => this.#getTabMoveState(tab));
moveActionCallback(); moveActionCallback();
// Clear tabs cache after moving nodes because the order of tabs may have // Clear tabs cache after moving nodes because the order of tabs may have
// changed. // changed.
this.tabContainer._invalidateCachedTabs(); this.tabContainer._invalidateCachedTabs();
this._lastRelatedTabMap = new WeakMap(); this._lastRelatedTabMap = new WeakMap();
this._updateTabsAfterInsert(); this._updateTabsAfterInsert();
if (wasFocused) { if (wasFocused) {
this.selectedTab.focus(); this.selectedTab.focus();
} }
if (aTab.selected) { for (let i = 0; i < tabs.length; i++) {
this.tabContainer._handleTabSelect(true); let tab = tabs[i];
} if (tab.selected) {
this.tabContainer._handleTabSelect(true);
}
if (tab.pinned) {
this.tabContainer._positionPinnedTabs();
}
if (aTab.pinned) { let currentTabState = this.#getTabMoveState(tab);
this.tabContainer._positionPinnedTabs(); this.#notifyOnTabMove(
} tab,
// Pinning/unpinning vertical tabs, and moving tabs into tab groups, both bypass moveTabTo. previousTabStates[i],
// We still want to check whether its worth dispatching an event. currentTabState,
if (this.isTab(aTab) && oldPosition != aTab._tPos) { metricsContext
let evt = document.createEvent("UIEvents"); );
evt.initUIEvent("TabMove", true, false, window, oldPosition);
aTab.dispatchEvent(evt);
} }
} }
@ -8696,11 +8838,7 @@ var TabContextMenu = {
} }
item.classList.add("menuitem-iconic"); item.classList.add("menuitem-iconic");
if (group.collapsed) { item.classList.add("tab-group-icon");
item.classList.add("tab-group-icon-collapsed");
} else {
item.classList.add("tab-group-icon");
}
item.style.setProperty( item.style.setProperty(
"--tab-group-color", "--tab-group-color",
group.style.getPropertyValue("--tab-group-color") group.style.getPropertyValue("--tab-group-color")
@ -9056,8 +9194,16 @@ var TabContextMenu = {
gTabsPanel.hideAllTabsPanel(); gTabsPanel.hideAllTabsPanel();
}, },
/**
* @param {MozTabbrowserTabGroup} group
*/
moveTabsToGroup(group) { moveTabsToGroup(group) {
group.addTabs(this.contextTabs); group.addTabs(
this.contextTabs,
gBrowser.TabMetrics.userTriggeredContext(
gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU
)
);
group.ownerGlobal.focus(); group.ownerGlobal.focus();
}, },

View file

@ -7,8 +7,8 @@
// This is loaded into chrome windows with the subscript loader. Wrap in // This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`. // a block to prevent accidentally leaking globals onto `window`.
{ {
const { TabGroupMetrics } = ChromeUtils.importESModule( const { TabMetrics } = ChromeUtils.importESModule(
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs" "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs"
); );
const { TabStateFlusher } = ChromeUtils.importESModule( const { TabStateFlusher } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs" "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
@ -448,10 +448,12 @@
document document
.getElementById("tabGroupEditor_deleteGroup") .getElementById("tabGroupEditor_deleteGroup")
.addEventListener("command", () => { .addEventListener("command", () => {
gBrowser.removeTabGroup(this.activeGroup, { gBrowser.removeTabGroup(
isUserTriggered: true, this.activeGroup,
telemetrySource: TabGroupMetrics.METRIC_SOURCE.TAB_GROUP_MENU, TabMetrics.userTriggeredContext(
}); TabMetrics.METRIC_SOURCE.TAB_GROUP_MENU
)
);
}); });
this.panel.addEventListener("popupshown", this); this.panel.addEventListener("popupshown", this);

View file

@ -234,9 +234,11 @@
/** /**
* add tabs to the group * add tabs to the group
* *
* @param tabs array of tabs to add * @param {MozTabbrowserTab[]} tabs
* @param {TabMetricsContext} [metricsContext]
* Optional context to record for metrics purposes.
*/ */
addTabs(tabs) { addTabs(tabs, metricsContext) {
for (let tab of tabs) { for (let tab of tabs) {
let tabToMove = let tabToMove =
this.ownerGlobal === tab.ownerGlobal this.ownerGlobal === tab.ownerGlobal
@ -245,7 +247,7 @@
tabIndex: gBrowser.tabs.at(-1)._tPos + 1, tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
selectTab: tab.selected, selectTab: tab.selected,
}); });
gBrowser.moveTabToGroup(tabToMove, this); gBrowser.moveTabToGroup(tabToMove, this, metricsContext);
} }
this.#lastAddedTo = Date.now(); this.#lastAddedTo = Date.now();
} }

View file

@ -9,21 +9,20 @@
// This is loaded into all browser windows. Wrap in a block to prevent // This is loaded into all browser windows. Wrap in a block to prevent
// leaking to window scope. // leaking to window scope.
{ {
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
});
const TAB_PREVIEW_PREF = "browser.tabs.hoverPreview.enabled"; const TAB_PREVIEW_PREF = "browser.tabs.hoverPreview.enabled";
const DIRECTION_BACKWARD = -1; const DIRECTION_BACKWARD = -1;
const DIRECTION_FORWARD = 1; const DIRECTION_FORWARD = 1;
const isTab = element => gBrowser.isTab(element); const isTab = element => gBrowser.isTab(element);
const isTabGroup = element => gBrowser.isTabGroup(element);
const isTabGroupLabel = element => gBrowser.isTabGroupLabel(element); const isTabGroupLabel = element => gBrowser.isTabGroupLabel(element);
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @returns {boolean}
* `true` if element is a `<tab-group>`
*/
const isTabGroup = element => !!(element?.tagName == "tab-group");
class MozTabbrowserTabs extends MozElements.TabsBase { class MozTabbrowserTabs extends MozElements.TabsBase {
static observedAttributes = ["orient"]; static observedAttributes = ["orient"];
@ -1060,6 +1059,10 @@
var dropEffect = dt.dropEffect; var dropEffect = dt.dropEffect;
var draggedTab; var draggedTab;
let movingTabs; let movingTabs;
/** @type {TabMetricsContext} */
const dropMetricsContext = lazy.TabMetrics.userTriggeredContext(
lazy.TabMetrics.METRIC_SOURCE.DRAG_AND_DROP
);
if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) { if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) {
// tab copy or move // tab copy or move
draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
@ -1084,7 +1087,7 @@
duplicatedDraggedTab = duplicatedTab; duplicatedDraggedTab = duplicatedTab;
} }
} }
gBrowser.moveTabsBefore(duplicatedTabs, dropTarget); gBrowser.moveTabsBefore(duplicatedTabs, dropTarget, dropMetricsContext);
if (draggedTab.container != this || event.shiftKey) { if (draggedTab.container != this || event.shiftKey) {
this.selectedItem = duplicatedDraggedTab; this.selectedItem = duplicatedDraggedTab;
} }
@ -1172,15 +1175,23 @@
let moveTabs = () => { let moveTabs = () => {
if (dropIndex !== undefined) { if (dropIndex !== undefined) {
for (let tab of movingTabs) { for (let tab of movingTabs) {
gBrowser.moveTabTo(tab, { elementIndex: dropIndex }); gBrowser.moveTabTo(
tab,
{ elementIndex: dropIndex },
dropMetricsContext
);
if (!directionForward) { if (!directionForward) {
dropIndex++; dropIndex++;
} }
} }
} else if (dropBefore) { } else if (dropBefore) {
gBrowser.moveTabsBefore(movingTabs, dropElement); gBrowser.moveTabsBefore(
movingTabs,
dropElement,
dropMetricsContext
);
} else { } else {
gBrowser.moveTabsAfter(movingTabs, dropElement); gBrowser.moveTabsAfter(movingTabs, dropElement, dropMetricsContext);
} }
this.#expandGroupOnDrop(draggedTab); this.#expandGroupOnDrop(draggedTab);
}; };

View file

@ -168,6 +168,61 @@ tabgroup:
type: string type: string
expires: never expires: never
reopen:
type: event
description: >
Recorded when a user reopens a saved tab group
notification_emails:
- dao@mozilla.com
- dwalker@mozilla.com
- jswinarton@mozilla.com
- dwalker@mozilla.com
bugs:
- https://bugzil.la/1938425
data_reviews:
- https://bugzil.la/1938425
extra_keys:
source:
description: The surface used to find and recall the saved group
type: string
layout:
description: The tabs layout (horizontal or vertical)
type: string
id:
description: The ID of the tab group. Tab group IDs are derived from their creation timestamps and have no other relationship to any tab group metadata.
type: string
type:
description: Whether the user reopened a saved group or a deleted group.
type: string
expires: never
add_tab:
type: event
disabled: true # To be controlled by server knobs during Firefox 138 launch due to expected high volume
description: >
Recorded when the user adds one or more ungrouped tabs to an existing tab group
notification_emails:
- dao@mozilla.com
- jswinarton@mozilla.com
- sthompson@mozilla.com
bugs:
- https://bugzil.la/1938424
data_reviews:
- https://bugzil.la/1938424
data_sensitivity:
- interaction
extra_keys:
source:
description: The system, surface, or control the user used to add the tab(s) to the tab group
type: string
tabs:
description: The number of tabs added to the tab group
type: quantity
layout:
description: The layout of the tab strip when the tabs were added (either "horizontal" or "vertical")
type: string
expires: never
active_groups: active_groups:
type: labeled_quantity type: labeled_quantity
description: > description: >

View file

@ -13,7 +13,7 @@ MOZ_SRC_FILES += [
"NewTabPagePreloading.sys.mjs", "NewTabPagePreloading.sys.mjs",
"OpenInTabsUtils.sys.mjs", "OpenInTabsUtils.sys.mjs",
"SmartTabGrouping.sys.mjs", "SmartTabGrouping.sys.mjs",
"TabGroupMetrics.sys.mjs", "TabMetrics.sys.mjs",
"TabsList.sys.mjs", "TabsList.sys.mjs",
"TabUnloader.sys.mjs", "TabUnloader.sys.mjs",
] ]

View file

@ -580,6 +580,22 @@ add_task(async function test_TabGroupEvents() {
tabGroupCollapsedTrigger.uninit(); tabGroupCollapsedTrigger.uninit();
}); });
add_task(async function test_moveTabGroup() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let group = gBrowser.addTabGroup([tab1, tab2]);
let tabMoveEvents = Promise.all([
BrowserTestUtils.waitForEvent(tab1, "TabMove"),
BrowserTestUtils.waitForEvent(tab2, "TabMove"),
]);
info("moving tab group and awaiting TabMove events");
gBrowser.moveTabToStart(group);
await tabMoveEvents;
await removeTabGroup(group);
});
add_task(async function test_moveTabBetweenGroups() { add_task(async function test_moveTabBetweenGroups() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank"); let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
@ -1686,19 +1702,19 @@ add_task(async function test_tabGroupCreatePanel() {
let tabgroupPanel = tabgroupEditor.panel; let tabgroupPanel = tabgroupEditor.panel;
let nameField = tabgroupPanel.querySelector("#tab-group-name"); let nameField = tabgroupPanel.querySelector("#tab-group-name");
let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); let tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
let group;
let openCreatePanel = async () => { let openCreatePanel = async () => {
let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown"); let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown");
group = gBrowser.addTabGroup([tab], { let group = gBrowser.addTabGroup([tab], {
color: "cyan", color: "cyan",
label: "Food", label: "Food",
isUserTriggered: true, isUserTriggered: true,
}); });
await panelShown; await panelShown;
return group;
}; };
await openCreatePanel(); let group = await openCreatePanel();
Assert.equal(tabgroupPanel.state, "open", "Create panel is visible"); Assert.equal(tabgroupPanel.state, "open", "Create panel is visible");
Assert.ok(tabgroupEditor.createMode, "Group editor is in create mode"); Assert.ok(tabgroupEditor.createMode, "Group editor is in create mode");
// Edit panel should be populated with correct group details // Edit panel should be populated with correct group details
@ -1725,14 +1741,14 @@ add_task(async function test_tabGroupCreatePanel() {
Assert.ok(!tab.group, "Tab is ungrouped after hitting Cancel"); Assert.ok(!tab.group, "Tab is ungrouped after hitting Cancel");
info("New group should be removed after hitting Esc"); info("New group should be removed after hitting Esc");
await openCreatePanel(); group = await openCreatePanel();
panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden"); panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden");
EventUtils.synthesizeKey("KEY_Escape"); EventUtils.synthesizeKey("KEY_Escape");
await panelHidden; await panelHidden;
Assert.ok(!tab.group, "Tab is ungrouped after hitting Esc"); Assert.ok(!tab.group, "Tab is ungrouped after hitting Esc");
info("New group should remain when dismissing panel"); info("New group should remain when dismissing panel");
await openCreatePanel(); group = await openCreatePanel();
panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden"); panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden");
tabgroupPanel.hidePopup(); tabgroupPanel.hidePopup();
await panelHidden; await panelHidden;
@ -1742,7 +1758,7 @@ add_task(async function test_tabGroupCreatePanel() {
group.ungroupTabs(); group.ungroupTabs();
info("Panel inputs should work correctly"); info("Panel inputs should work correctly");
await openCreatePanel(); group = await openCreatePanel();
nameField.focus(); nameField.focus();
nameField.value = ""; nameField.value = "";
EventUtils.sendString("Shopping"); EventUtils.sendString("Shopping");
@ -1819,7 +1835,7 @@ add_task(async function test_tabGroupCreatePanel() {
tabGroupCreatedTrigger.uninit(); tabGroupCreatedTrigger.uninit();
}); });
async function createTabGroupAndOpenEditPanel(tabs = []) { async function createTabGroupAndOpenEditPanel(tabs = [], label = "") {
let tabgroupEditor = document.getElementById("tab-group-editor"); let tabgroupEditor = document.getElementById("tab-group-editor");
let tabgroupPanel = tabgroupEditor.panel; let tabgroupPanel = tabgroupEditor.panel;
if (!tabs.length) { if (!tabs.length) {
@ -1828,7 +1844,7 @@ async function createTabGroupAndOpenEditPanel(tabs = []) {
}); });
tabs = [tab]; tabs = [tab];
} }
let group = gBrowser.addTabGroup(tabs, { color: "cyan", label: "Food" }); let group = gBrowser.addTabGroup(tabs, { color: "cyan", label });
let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown"); let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown");
EventUtils.synthesizeMouseAtCenter( EventUtils.synthesizeMouseAtCenter(
@ -1844,7 +1860,10 @@ async function createTabGroupAndOpenEditPanel(tabs = []) {
} }
add_task(async function test_tabGroupPanelAddTab() { add_task(async function test_tabGroupPanelAddTab() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(); let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_tabGroupPanelAddTab"
);
let tabgroupPanel = tabgroupEditor.panel; let tabgroupPanel = tabgroupEditor.panel;
let addNewTabButton = tabgroupPanel.querySelector( let addNewTabButton = tabgroupPanel.querySelector(
@ -1858,13 +1877,14 @@ add_task(async function test_tabGroupPanelAddTab() {
Assert.ok(tabgroupPanel.state === "closed", "Group editor is closed"); Assert.ok(tabgroupPanel.state === "closed", "Group editor is closed");
Assert.equal(group.tabs.length, 2, "Group has 2 tabs"); Assert.equal(group.tabs.length, 2, "Group has 2 tabs");
for (let tab of group.tabs) { await removeTabGroup(group);
BrowserTestUtils.removeTab(tab);
}
}); });
add_task(async function test_tabGroupPanelUngroupTabs() { add_task(async function test_tabGroupPanelUngroupTabs() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(); let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_tabGroupPanelAddTab"
);
let tabgroupPanel = tabgroupEditor.panel; let tabgroupPanel = tabgroupEditor.panel;
let tab = group.tabs[0]; let tab = group.tabs[0];
let ungroupTabsButton = tabgroupPanel.querySelector( let ungroupTabsButton = tabgroupPanel.querySelector(
@ -1914,7 +1934,10 @@ add_task(async function test_moveGroupToNewWindow() {
"about:mozilla is third" "about:mozilla is third"
); );
}; };
let { group } = await createTabGroupAndOpenEditPanel(tabs); let { group } = await createTabGroupAndOpenEditPanel(
tabs,
"test_moveGroupToNewWindow"
);
let newWindowOpened = BrowserTestUtils.waitForNewWindow(); let newWindowOpened = BrowserTestUtils.waitForNewWindow();
document.getElementById("tabGroupEditor_moveGroupToNewWindow").click(); document.getElementById("tabGroupEditor_moveGroupToNewWindow").click();
@ -1964,7 +1987,7 @@ add_task(async function test_moveGroupToNewWindow() {
!moveGroupButton.disabled, !moveGroupButton.disabled,
"Button is enabled again when additional tab present" "Button is enabled again when additional tab present"
); );
await removeTabGroup(movedGroup);
await BrowserTestUtils.closeWindow(newWin, { animate: false }); await BrowserTestUtils.closeWindow(newWin, { animate: false });
}); });
@ -1973,7 +1996,10 @@ add_task(async function test_moveGroupToNewWindow() {
* group is not saveable. * group is not saveable.
*/ */
add_task(async function test_saveDisabledForUnimportantGroup() { add_task(async function test_saveDisabledForUnimportantGroup() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(); let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_saveDisabledForUnimportantGroups"
);
let saveAndCloseGroupButton = tabgroupEditor.panel.querySelector( let saveAndCloseGroupButton = tabgroupEditor.panel.querySelector(
"#tabGroupEditor_saveAndCloseGroup" "#tabGroupEditor_saveAndCloseGroup"
); );
@ -1987,7 +2013,7 @@ add_task(async function test_saveDisabledForUnimportantGroup() {
); );
tabgroupEditor.panel.hidePopup(); tabgroupEditor.panel.hidePopup();
await panelHidden; await panelHidden;
await gBrowser.removeTabGroup(group); await removeTabGroup(group);
}); });
add_task(async function test_saveAndCloseGroup() { add_task(async function test_saveAndCloseGroup() {
@ -1997,7 +2023,10 @@ add_task(async function test_saveAndCloseGroup() {
tabGroupSavedTrigger.init(triggerHandler); tabGroupSavedTrigger.init(triggerHandler);
let tab = await addTab("about:mozilla"); let tab = await addTab("about:mozilla");
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel([tab]); let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[tab],
"test_saveAndCloseGroup"
);
let tabgroupPanel = tabgroupEditor.panel; let tabgroupPanel = tabgroupEditor.panel;
await TabStateFlusher.flush(tab.linkedBrowser); await TabStateFlusher.flush(tab.linkedBrowser);
let saveAndCloseGroupButton = tabgroupPanel.querySelector( let saveAndCloseGroupButton = tabgroupPanel.querySelector(
@ -2093,6 +2122,7 @@ add_task(async function test_pinningInteractionsWithTabGroups() {
); );
moreTabs.concat(tabs).forEach(tab => BrowserTestUtils.removeTab(tab)); moreTabs.concat(tabs).forEach(tab => BrowserTestUtils.removeTab(tab));
await removeTabGroup(group);
}); });
add_task(async function test_pinFirstGroupedTab() { add_task(async function test_pinFirstGroupedTab() {
@ -2120,6 +2150,7 @@ add_task(async function test_adoptTab() {
Assert.equal(adoptedTab._tPos, 1, "tab adopted into expected position"); Assert.equal(adoptedTab._tPos, 1, "tab adopted into expected position");
Assert.equal(adoptedTab.group, group, "tab adopted into tab group"); Assert.equal(adoptedTab.group, group, "tab adopted into tab group");
await removeTabGroup(group);
await BrowserTestUtils.closeWindow(newWin, { animate: false }); await BrowserTestUtils.closeWindow(newWin, { animate: false });
}); });
@ -2229,3 +2260,33 @@ add_task(async function test_bug1957723_addTabsByIndex() {
gBrowser.removeAllTabsBut(initialTab); gBrowser.removeAllTabsBut(initialTab);
}); });
add_task(async function test_bug1959438_duplicateTabJustBeforeGroup() {
let initialTab = gBrowser.tabs[0];
let triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
const tabs = createManyTabs(3);
gBrowser.addTabGroup(tabs);
Assert.equal(gBrowser.tabs.length, 4, "Tab strip starts with four tabs");
gBrowser.selectTabAtIndex(0);
// Simulate an addTab call similar to what would be called when a tab is
// duplicated. This produces a situation where addTab has no index, but knows
// it needs to create one next to the currently selected tab, and guesses for
// itself.
// If this happens next to a tab group, the resulting element index will
// point to the tab group label.
gBrowser.addTab("https://example.com", {
index: undefined,
relatedToCurrent: true,
ownerTab: gBrowser.selectedTab,
triggeringPrincipal,
});
// This will fail if the tab ends up merged with the tab label.
Assert.equal(gBrowser.tabs.length, 5, "A new tab was added to the tab strip");
gBrowser.removeAllTabsBut(initialTab);
});

View file

@ -6,6 +6,10 @@ const { TabStateFlusher } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs" "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
); );
const { UrlbarTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/UrlbarTestUtils.sys.mjs"
);
let resetTelemetry = async () => { let resetTelemetry = async () => {
await Services.fog.testFlushAllChildren(); await Services.fog.testFlushAllChildren();
Services.fog.testResetFOG(); Services.fog.testResetFOG();
@ -15,10 +19,17 @@ let resetTelemetry = async () => {
let win; let win;
add_setup(async () => { add_setup(async () => {
await SpecialPowers.pushPrefEnv({
set: [
["browser.tabs.groups.enabled", true],
["browser.urlbar.scotchBonnet.enableOverride", true],
],
});
win = await BrowserTestUtils.openNewBrowserWindow(); win = await BrowserTestUtils.openNewBrowserWindow();
win.gTabsPanel.init(); win.gTabsPanel.init();
registerCleanupFunction(async () => { registerCleanupFunction(async () => {
await BrowserTestUtils.closeWindow(win); await BrowserTestUtils.closeWindow(win);
await SpecialPowers.popPrefEnv();
}); });
}); });
@ -349,56 +360,38 @@ async function closeTabsMenu() {
await hidden; await hidden;
} }
/**
* @param {XULToolbarButton} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let nodeWindow = triggerNode.ownerGlobal;
triggerNode.scrollIntoView();
const contextMenu = nodeWindow.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
nodeWindow
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}
/** /**
* Returns a new basic, unnamed tab group that is fully loaded in the browser * Returns a new basic, unnamed tab group that is fully loaded in the browser
* and in session state. * and in session state.
* *
* @returns {Promise<MozTabbrowserTabGroup>} * @returns {Promise<MozTabbrowserTabGroup>}
*/ */
async function makeTabGroup() { async function makeTabGroup(name = "") {
let tab = BrowserTestUtils.addTab(win.gBrowser, "https://example.com"); let tab = BrowserTestUtils.addTab(win.gBrowser, "https://example.com");
await BrowserTestUtils.browserLoaded(tab.linkedBrowser); await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
await TabStateFlusher.flush(tab.linkedBrowser); await TabStateFlusher.flush(tab.linkedBrowser);
let group = win.gBrowser.addTabGroup([tab]); let group = win.gBrowser.addTabGroup([tab], { label: name });
// Close the automatically-opened "create tab group" menu. // Close the automatically-opened "create tab group" menu.
win.gBrowser.tabGroupMenu.close(); win.gBrowser.tabGroupMenu.close();
return group; return group;
} }
/**
* Returns a basic tab group from makeTabGroup and saves it.
*
* @returns {string} the ID of the saved group
*/
async function saveAndCloseGroup(group) {
let closedObjectsChanged = TestUtils.topicObserved(
"sessionstore-closed-objects-changed"
);
group.ownerGlobal.SessionStore.addSavedTabGroup(group);
await removeTabGroup(group);
await closedObjectsChanged;
return group.id;
}
add_task(async function test_tabOverflowContextMenu_deleteOpenTabGroup() { add_task(async function test_tabOverflowContextMenu_deleteOpenTabGroup() {
await resetTelemetry(); await resetTelemetry();
@ -446,3 +439,194 @@ add_task(async function test_tabOverflowContextMenu_deleteOpenTabGroup() {
await resetTelemetry(); await resetTelemetry();
}); });
async function waitForReopenRecord() {
return BrowserTestUtils.waitForCondition(() => {
let tabGroupReopenTelemetry = Glean.tabgroup.reopen.testGetValue();
return tabGroupReopenTelemetry?.length > 0;
}, "Waiting for reopen telemetry to populate");
}
function assertReopenEvent({ id, source, layout, type }) {
let tabGroupReopenEvents = Glean.tabgroup.reopen.testGetValue();
Assert.equal(
tabGroupReopenEvents.length,
1,
"should have recorded one tabgroup.reopen event"
);
let [reopenEvent] = tabGroupReopenEvents;
Assert.deepEqual(
reopenEvent.extra,
{
id,
source,
layout,
type,
},
"should have recorded correct id, source, and layout for reopen event"
);
}
async function waitForNoActiveGroups() {
return BrowserTestUtils.waitForCondition(
() => !win.gBrowser.getAllTabGroups().length,
"waiting for an empty group list"
);
}
async function doReopenTests(useVerticalTabs) {
await waitForNoActiveGroups();
Assert.ok(!win.gBrowser.getAllTabGroups().length, "there are no tab groups");
Assert.ok(!win.SessionStore.savedGroups.length, "no saved groups");
let expectedLayout = useVerticalTabs ? "vertical" : "horizontal";
await SpecialPowers.pushPrefEnv({
set: [
["sidebar.revamp", true],
["sidebar.verticalTabs", useVerticalTabs],
],
});
let group = await makeTabGroup("reopen-test");
let groupId = await saveAndCloseGroup(group);
info("Restoring from overflow menu");
await waitForNoActiveGroups();
let menu = await openTabsMenu();
let groupItems = menu.querySelectorAll(
"#allTabsMenu-groupsView .all-tabs-group-action-button"
);
Assert.equal(groupItems.length, 1, "1 group in menu");
let groupButton = groupItems[0];
Assert.equal(
groupButton.getAttribute("data-tab-group-id"),
groupId,
"Correct group appears in menu"
);
groupButton.click();
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "tab_overflow",
layout: expectedLayout,
type: "saved",
});
await resetTelemetry();
await saveAndCloseGroup(win.gBrowser.getTabGroupById(groupId));
info("restoring saved group via undoClosetab");
await waitForNoActiveGroups();
undoCloseTab(undefined, win.__SSi);
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "recent",
layout: expectedLayout,
type: "saved",
});
await addTab("about:blank"); // removed by undoCloseTab
await saveAndCloseGroup(win.gBrowser.getTabGroupById(groupId));
await resetTelemetry();
info("restoring saved group from URLbar suggestion");
await waitForNoActiveGroups();
await UrlbarTestUtils.promiseAutocompleteResultPopup({
window: win,
waitForFocus,
value: "reopen-test",
fireInputEvent: true,
reopenOnBlur: true,
});
let reopenGroupButton = win.gURLBar.panel.querySelector(
`[data-action^="tabgroup"]`
);
Assert.ok(!!reopenGroupButton, "Reopen group action is present in results");
let closedObjectsChanged = TestUtils.topicObserved(
"sessionstore-closed-objects-changed"
);
await UrlbarTestUtils.promisePopupClose(win, () => {
EventUtils.synthesizeKey("KEY_Tab", {}, win);
EventUtils.synthesizeKey("KEY_Enter", {}, win);
});
await closedObjectsChanged;
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "suggest",
layout: expectedLayout,
type: "saved",
});
await win.gBrowser.removeTabGroup(win.gBrowser.getTabGroupById(groupId));
await resetTelemetry();
await SpecialPowers.popPrefEnv();
}
add_task(async function test_reopenSavedGroupTelemetry() {
info("Perform reopen tests in horizontal tabs mode");
await doReopenTests(false);
info("Perform reopen tests in vertical tabs mode");
await doReopenTests(true);
});
add_task(async function test_tabContextMenu_addTabsToGroup() {
await resetTelemetry();
// `tabgroup.add_tab` is disabled by default and enabled by server knobs,
// so this test needs to enable it manually in order to test it.
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"tabgroup.add_tab": true,
},
})
);
info("set up a tab group to test with");
let group = await makeTabGroup();
let groupId = group.id;
info("create 8 ungrouped tabs to test with");
let moreTabs = Array.from({ length: 8 }).map(() =>
BrowserTestUtils.addTab(win.gBrowser, "https://example.com")
);
info("select first ungrouped tab and multi-select three more tabs");
win.gBrowser.selectedTab = moreTabs[0];
moreTabs.slice(1, 4).forEach(tab => win.gBrowser.addToMultiSelectedTabs(tab));
await BrowserTestUtils.waitForCondition(() => {
return win.gBrowser.multiSelectedTabsCount == 4;
}, "Wait for Tabbrowser to update the multiselected tab state");
let menu = await getContextMenu(win.gBrowser.selectedTab, "tabContextMenu");
let moveTabToGroupItem = win.document.getElementById(
"context_moveTabToGroup"
);
let tabGroupButton = moveTabToGroupItem.querySelector(
`[tab-group-id="${groupId}"]`
);
tabGroupButton.click();
await closeContextMenu(menu);
await BrowserTestUtils.waitForCondition(() => {
return Glean.tabgroup.addTab.testGetValue() !== null;
}, "Wait for a Glean event to be recorded");
let [addTabEvent] = Glean.tabgroup.addTab.testGetValue();
Assert.deepEqual(
addTabEvent.extra,
{
source: "tab_menu",
tabs: "4",
layout: "horizontal",
},
"should have recorded the correct event metadata"
);
for (let tab of moreTabs) {
BrowserTestUtils.removeTab(tab);
}
await removeTabGroup(group);
await resetTelemetry();
});

View file

@ -601,3 +601,36 @@ async function removeTabGroup(group) {
await group.ownerGlobal.gBrowser.removeTabGroup(group, { animate: false }); await group.ownerGlobal.gBrowser.removeTabGroup(group, { animate: false });
await removePromise; await removePromise;
} }
/**
* @param {Node} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let win = triggerNode.ownerGlobal;
triggerNode.scrollIntoView({ behavior: "instant" });
const contextMenu = win.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
win
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}

View file

@ -11,12 +11,19 @@ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const kWebAppWindowFeatures = const kWebAppWindowFeatures =
"chrome,dialog=no,titlebar,close,toolbar,location,personalbar=no,status,menubar=no,resizable,minimizable,scrollbars"; "chrome,dialog=no,titlebar,close,toolbar,location,personalbar=no,status,menubar=no,resizable,minimizable,scrollbars";
let lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
export let TaskbarTabs = { export let TaskbarTabs = {
async init(window) { async init(window) {
if ( if (
AppConstants.platform != "win" || AppConstants.platform != "win" ||
!Services.prefs.getBoolPref(kEnabledPref, false) || !Services.prefs.getBoolPref(kEnabledPref, false) ||
window.document.documentElement.hasAttribute("taskbartab") window.document.documentElement.hasAttribute("taskbartab") ||
!window.toolbar.visible ||
lazy.PrivateBrowsingUtils.isWindowPrivate(window)
) { ) {
return; return;
} }

View file

@ -0,0 +1,5 @@
[DEFAULT]
# Startup pref, needs to be set in [DEFAULT]
prefs = ["security.allow_unsafe_dangerous_privileged_evil_eval=true"]
["browser_csp_eval.js"]

View file

@ -0,0 +1,18 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(function eval_allowed_with_special_pref() {
is(
window.location.pathname,
"/content/browser.xhtml",
"Running in browser.xhtml"
);
// eslint-disable-next-line no-eval
eval("window.test_code_ran = 1;");
is(window.test_code_ran, 1, "eval() executed successfully");
delete window.test_code_ran;
});

View file

@ -655,19 +655,10 @@ var FullPageTranslationsPanel = new (class {
"full-page-translations-panel-view-default" "full-page-translations-panel-view-default"
); );
if (!this._hasShownPanel) { if (TranslationsParent.hasUserEverTranslated()) {
actor.firstShowUriSpec = gBrowser.currentURI.spec;
}
if (
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
document.l10n.setAttributes(header, "translations-panel-header");
actor.firstShowUriSpec = null;
intro.hidden = true; intro.hidden = true;
document.l10n.setAttributes(header, "translations-panel-header");
} else { } else {
Services.prefs.setBoolPref("browser.translations.panelShown", true);
intro.hidden = false; intro.hidden = false;
document.l10n.setAttributes(header, "translations-panel-intro-header"); document.l10n.setAttributes(header, "translations-panel-intro-header");
} }
@ -1450,11 +1441,9 @@ var FullPageTranslationsPanel = new (class {
async #showEngineError(actor) { async #showEngineError(actor) {
const { button } = this.buttonElements; const { button } = this.buttonElements;
await this.#ensureLangListsBuilt(); await this.#ensureLangListsBuilt();
if (!this.#isShowingDefaultView()) { await this.#showDefaultView(actor).catch(e => {
await this.#showDefaultView(actor).catch(e => { this.console?.error(e);
this.console?.error(e); });
});
}
this.elements.error.hidden = false; this.elements.error.hidden = false;
this.#showError({ this.#showError({
message: "translations-panel-error-translating", message: "translations-panel-error-translating",
@ -1647,10 +1636,7 @@ var FullPageTranslationsPanel = new (class {
// Follow the same rules for displaying the first-run intro text for the // Follow the same rules for displaying the first-run intro text for the
// button's accessible tooltip label. // button's accessible tooltip label.
if ( if (TranslationsParent.hasUserEverTranslated()) {
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
document.l10n.setAttributes( document.l10n.setAttributes(
button, button,
"urlbar-translations-button2" "urlbar-translations-button2"
@ -1688,10 +1674,3 @@ var FullPageTranslationsPanel = new (class {
} }
}; };
})(); })();
XPCOMUtils.defineLazyPreferenceGetter(
FullPageTranslationsPanel,
"_hasShownPanel",
"browser.translations.panelShown",
false
);

View file

@ -27,7 +27,7 @@ add_task(
await FullPageTranslationsTestUtils.openPanel({ await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
await FullPageTranslationsTestUtils.clickTranslateButton(); await FullPageTranslationsTestUtils.clickTranslateButton();

View file

@ -27,7 +27,7 @@ add_task(
await FullPageTranslationsTestUtils.openPanel({ await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
await FullPageTranslationsTestUtils.clickTranslateButton(); await FullPageTranslationsTestUtils.clickTranslateButton();

View file

@ -26,7 +26,7 @@ add_task(async function test_browser_translations_full_page_multiple_windows() {
await FullPageTranslationsTestUtils.openPanel({ await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
await FullPageTranslationsTestUtils.clickCancelButton(); await FullPageTranslationsTestUtils.clickCancelButton();

View file

@ -36,14 +36,14 @@ add_task(async function test_translations_moz_extension() {
"The button is available." "The button is available."
); );
is(button.getAttribute("data-l10n-id"), "urlbar-translations-button2"); is(button.getAttribute("data-l10n-id"), "urlbar-translations-button-intro");
await FullPageTranslationsTestUtils.assertPageIsUntranslated(runInPage); await FullPageTranslationsTestUtils.assertPageIsUntranslated(runInPage);
await FullPageTranslationsTestUtils.openPanel({ await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
await FullPageTranslationsTestUtils.clickTranslateButton({ await FullPageTranslationsTestUtils.clickTranslateButton({

View file

@ -29,7 +29,7 @@ add_task(async function test_browser_translations_full_page_multiple_windows() {
await FullPageTranslationsTestUtils.openPanel({ await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
await FullPageTranslationsTestUtils.clickTranslateButton({ await FullPageTranslationsTestUtils.clickTranslateButton({
downloadHandler: testPage1.resolveDownloads, downloadHandler: testPage1.resolveDownloads,

View file

@ -21,7 +21,7 @@ add_task(async function test_translations_panel_a11y_focus() {
expectedFromLanguage: "es", expectedFromLanguage: "es",
expectedToLanguage: "en", expectedToLanguage: "en",
openWithKeyboard: true, openWithKeyboard: true,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault, onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
}); });
is( is(

Some files were not shown because too many files have changed in this diff Show more