mirror of
https://github.com/zen-browser/desktop.git
synced 2026-09-10 23:35:32 +00:00
gh-15266: Add support for sharing spaces, folders and split views (gh-15283)
This commit is contained in:
@@ -32,9 +32,7 @@ zen-share-import-error-toast = Couldn't import this share
|
||||
zen-share-import-error-dead-description = The link has expired or doesn't exist
|
||||
zen-share-import-error-invalid-description = The shared data is invalid
|
||||
|
||||
zen-share-confirm-window =
|
||||
.title = Share with your buds
|
||||
zen-share-confirm-title = Share with your buds
|
||||
zen-share-confirm-title = Share link
|
||||
zen-share-confirm-dialog =
|
||||
.buttonlabelaccept = Share
|
||||
zen-share-confirm-description = Zen uploads a copy of these tabs to the share server and copies a link anyone can open. The link expires after 30 days.
|
||||
|
||||
@@ -38,9 +38,16 @@ var gZenMarketplaceManager = {
|
||||
|
||||
this.__hasInitializedEvents = true;
|
||||
|
||||
await this._buildModsList();
|
||||
|
||||
Services.prefs.addObserver(gZenMods.updatePref, this);
|
||||
window.addEventListener(
|
||||
"unload",
|
||||
() => {
|
||||
Services.prefs.removeObserver(gZenMods.updatePref, this);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
await this._buildModsList();
|
||||
|
||||
const checkForUpdateClick = (event) => {
|
||||
if (event.target === checkForUpdates) {
|
||||
@@ -69,7 +76,6 @@ var gZenMarketplaceManager = {
|
||||
});
|
||||
|
||||
window.addEventListener("unload", () => {
|
||||
Services.prefs.removeObserver(gZenMods.updatePref, this);
|
||||
this.__hasInitializedEvents = false;
|
||||
|
||||
document.removeEventListener("ZenModsMarketplace:CheckForUpdatesFinished", this);
|
||||
|
||||
@@ -649,9 +649,10 @@ class nsZenBoostsManager {
|
||||
);
|
||||
|
||||
// Close the editor if the tab is switched
|
||||
const onTabSelect = editor.close.bind(editor);
|
||||
parentWindow.gBrowser.tabContainer.addEventListener(
|
||||
"TabSelect",
|
||||
editor.close.bind(editor),
|
||||
onTabSelect,
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
@@ -661,13 +662,24 @@ class nsZenBoostsManager {
|
||||
onLocationChange: webProgress => {
|
||||
if (webProgress.isTopLevel) {
|
||||
editor.close();
|
||||
parentWindow.gBrowser.removeTabsProgressListener(progressListener);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
parentWindow.gBrowser.addProgressListener(progressListener);
|
||||
|
||||
editor.addEventListener(
|
||||
"unload",
|
||||
() => {
|
||||
parentWindow.gBrowser.tabContainer.removeEventListener(
|
||||
"TabSelect",
|
||||
onTabSelect
|
||||
);
|
||||
parentWindow.gBrowser.removeProgressListener(progressListener);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
// Give the domain
|
||||
editor.domain = domain;
|
||||
editor.openerWindow = parentWindow;
|
||||
|
||||
@@ -257,7 +257,7 @@ window.gZenUIManager = {
|
||||
};
|
||||
},
|
||||
|
||||
updateTabsToolbar() {
|
||||
updateTabsToolbar(fromResizeEvent = false) {
|
||||
const kUrlbarHeight = 333;
|
||||
gURLBar.style.setProperty(
|
||||
"--zen-urlbar-top",
|
||||
@@ -270,8 +270,8 @@ window.gZenUIManager = {
|
||||
gZenVerticalTabsManager.actualWindowButtons.removeAttribute(
|
||||
"zen-has-hover"
|
||||
);
|
||||
gZenVerticalTabsManager.recalculateURLBarHeight(true);
|
||||
if (!this._preventToolbarRebuild) {
|
||||
gZenVerticalTabsManager.recalculateURLBarHeight(!fromResizeEvent);
|
||||
if (!this._preventToolbarRebuild && !fromResizeEvent) {
|
||||
setTimeout(() => {
|
||||
gZenWorkspaces.updateTabsContainers();
|
||||
}, 0);
|
||||
@@ -1278,8 +1278,16 @@ window.gZenVerticalTabsManager = {
|
||||
if (gZenWorkspaces._processingResize) {
|
||||
return;
|
||||
}
|
||||
this._pendingUrlbarFormatUpdate ||= updateFormat;
|
||||
if (this._urlbarHeightRecalcScheduled) {
|
||||
return;
|
||||
}
|
||||
this._urlbarHeightRecalcScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
delete this._urlbarHeightRecalcScheduled;
|
||||
const shouldUpdateFormat = this._pendingUrlbarFormatUpdate;
|
||||
delete this._pendingUrlbarFormatUpdate;
|
||||
gURLBar.removeAttribute("--urlbar-height");
|
||||
let height;
|
||||
if (!this._hasSetSingleToolbar) {
|
||||
@@ -1290,7 +1298,7 @@ window.gZenVerticalTabsManager = {
|
||||
if (typeof height !== "undefined") {
|
||||
gURLBar.style.setProperty("--urlbar-height", `${height}px`);
|
||||
}
|
||||
if (updateFormat) {
|
||||
if (shouldUpdateFormat) {
|
||||
gURLBar.zenFormatURLValue();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
.all-tabs-item > toolbarbutton.all-tabs-container-indicator::before,
|
||||
.urlbarView-button,
|
||||
.downloadButton,
|
||||
.titlebar-button,
|
||||
#downloads-indicator-progress-inner,
|
||||
#smartwindow-ask-button-inner,
|
||||
#PanelUI-fxa-menu-manage-account-button,
|
||||
|
||||
@@ -524,13 +524,20 @@ class nsZenMods extends nsZenPreloadedFeature {
|
||||
console.error("[ZenMods]: Error loading Zen Mods:", e);
|
||||
}
|
||||
|
||||
Services.prefs.addObserver(
|
||||
this.updatePref,
|
||||
this.#rebuildModsStylesheet.bind(this)
|
||||
);
|
||||
Services.prefs.addObserver(
|
||||
"zen.themes.disable-all",
|
||||
this.#handleDisableMods.bind(this)
|
||||
const rebuildObserver = this.#rebuildModsStylesheet.bind(this);
|
||||
const disableObserver = this.#handleDisableMods.bind(this);
|
||||
Services.prefs.addObserver(this.updatePref, rebuildObserver);
|
||||
Services.prefs.addObserver("zen.themes.disable-all", disableObserver);
|
||||
window.addEventListener(
|
||||
"unload",
|
||||
() => {
|
||||
Services.prefs.removeObserver(this.updatePref, rebuildObserver);
|
||||
Services.prefs.removeObserver(
|
||||
"zen.themes.disable-all",
|
||||
disableObserver
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ class nsZenShareManager extends nsZenDOMOperatedFeature {
|
||||
}
|
||||
try {
|
||||
const created = await lazy.ZenShareClient.createShare(
|
||||
{ version: SHARE_DOCUMENT_VERSION, shared: [item] },
|
||||
{ version: SHARE_DOCUMENT_VERSION, shared: item },
|
||||
{ name: this.#displayName() }
|
||||
);
|
||||
Cc["@mozilla.org/widget/clipboardhelper;1"]
|
||||
@@ -383,7 +383,7 @@ class nsZenShareManager extends nsZenDOMOperatedFeature {
|
||||
};
|
||||
try {
|
||||
const { doc } = await lazy.ZenShareClient.fetchSharePreview(share);
|
||||
item = doc.shared.find(entry => entry.type === "splitView");
|
||||
item = doc.shared?.type === "splitView" ? doc.shared : null;
|
||||
} catch (e) {
|
||||
console.error("ZenShare: could not load shared split view:", e);
|
||||
const descriptions = {
|
||||
@@ -498,7 +498,7 @@ class nsZenShareManager extends nsZenDOMOperatedFeature {
|
||||
return;
|
||||
}
|
||||
const { doc, name: sharerName } = preview;
|
||||
const first = doc.shared[0];
|
||||
const first = doc.shared;
|
||||
const type = first.type;
|
||||
|
||||
badgeTitle.textContent = first.name;
|
||||
@@ -649,18 +649,17 @@ class nsZenShareManager extends nsZenDOMOperatedFeature {
|
||||
}
|
||||
|
||||
async #importDocument(doc) {
|
||||
for (const item of doc.shared) {
|
||||
switch (item.type) {
|
||||
case "space":
|
||||
await this.#importSpace(item);
|
||||
break;
|
||||
case "folder":
|
||||
this.#importFolder(item, gZenWorkspaces.activeWorkspace);
|
||||
break;
|
||||
case "splitView":
|
||||
this.#importSplitView(item);
|
||||
break;
|
||||
}
|
||||
const item = doc.shared;
|
||||
switch (item.type) {
|
||||
case "space":
|
||||
await this.#importSpace(item);
|
||||
break;
|
||||
case "folder":
|
||||
this.#importFolder(item, gZenWorkspaces.activeWorkspace);
|
||||
break;
|
||||
case "splitView":
|
||||
this.#importSplitView(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"shared": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/shareable"
|
||||
}
|
||||
"$ref": "#/$defs/shareable"
|
||||
}
|
||||
},
|
||||
"required": ["shared"],
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
xmlns:html="http://www.w3.org/1999/xhtml"
|
||||
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||
id="zenShareConfirmDialog"
|
||||
data-l10n-id="zen-share-confirm-window"
|
||||
data-l10n-attrs="title"
|
||||
>
|
||||
<dialog
|
||||
buttons="accept,cancel"
|
||||
@@ -37,7 +35,7 @@
|
||||
|
||||
<script src="chrome://browser/content/zen-components/windows/zen-share-confirm.js"></script>
|
||||
|
||||
<vbox>
|
||||
<vbox style="padding-inline: 12px;">
|
||||
<html:h2
|
||||
data-l10n-id="zen-share-confirm-title"
|
||||
></html:h2>
|
||||
@@ -47,6 +45,7 @@
|
||||
data-l10n-id="zen-share-confirm-anonymous"
|
||||
/>
|
||||
<html:input
|
||||
style="margin-bottom: 12px;"
|
||||
id="zen-share-confirm-name"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
|
||||
@@ -3287,7 +3287,7 @@ class nsZenWorkspaces {
|
||||
if (!(!event || event.target === window)) {
|
||||
return;
|
||||
}
|
||||
gZenUIManager.updateTabsToolbar();
|
||||
gZenUIManager.updateTabsToolbar(!!event);
|
||||
// Check if workspace icons overflow the parent container
|
||||
let parent = this.workspaceIcons;
|
||||
if (!parent || this._processingResize) {
|
||||
|
||||
@@ -403,7 +403,7 @@ zen-workspace {
|
||||
}
|
||||
|
||||
:root:not(:is([animating-background], [swipe-gesture])) #navigator-toolbox:not([movingtab]) &:not([active]) {
|
||||
-moz-subtree-hidden-only-visually: 1;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ BROWSER_CHROME_MANIFESTS += [
|
||||
"media/browser.toml",
|
||||
"pinned/browser.toml",
|
||||
"popover/browser.toml",
|
||||
"share/browser.toml",
|
||||
"site_control/browser.toml",
|
||||
"space_routing/browser.toml",
|
||||
"spaces/browser.toml",
|
||||
|
||||
22
src/zen/tests/share/browser.toml
Normal file
22
src/zen/tests/share/browser.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# 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/.
|
||||
|
||||
[DEFAULT]
|
||||
prefs = [
|
||||
"zen.share.base-url=https://example.com",
|
||||
"zen.share.dont-ask-before-sharing=true",
|
||||
]
|
||||
support-files = [
|
||||
"head.js",
|
||||
]
|
||||
|
||||
["browser_share_client.js"]
|
||||
|
||||
["browser_share_import.js"]
|
||||
|
||||
["browser_share_serialize.js"]
|
||||
|
||||
["browser_share_split_link.js"]
|
||||
|
||||
["browser_share_url_parsing.js"]
|
||||
160
src/zen/tests/share/browser_share_client.js
Normal file
160
src/zen/tests/share/browser_share_client.js
Normal file
@@ -0,0 +1,160 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { HttpServer } = ChromeUtils.importESModule(
|
||||
"resource://testing-common/httpd.sys.mjs"
|
||||
);
|
||||
|
||||
const VALID_DOC = {
|
||||
version: "1",
|
||||
shared: {
|
||||
type: "space",
|
||||
name: "Test",
|
||||
items: [{ type: "tab", url: "https://example.com/", label: "T" }],
|
||||
},
|
||||
};
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
let lastRequest = null;
|
||||
let nextResponse = null;
|
||||
|
||||
function readBody(request) {
|
||||
return NetUtil.readInputStreamToString(
|
||||
request.bodyInputStream,
|
||||
request.bodyInputStream.available()
|
||||
);
|
||||
}
|
||||
|
||||
add_setup(async function () {
|
||||
server = new HttpServer();
|
||||
server.registerPathHandler("/api/shares", (request, response) => {
|
||||
lastRequest = {
|
||||
method: request.method,
|
||||
query: request.queryString,
|
||||
apiKey: request.hasHeader("x-api-key")
|
||||
? request.getHeader("x-api-key")
|
||||
: null,
|
||||
secretKey: request.hasHeader("x-secret-key")
|
||||
? request.getHeader("x-secret-key")
|
||||
: null,
|
||||
body: readBody(request),
|
||||
};
|
||||
if (nextResponse) {
|
||||
response.setStatusLine(request.httpVersion, nextResponse.status, "Err");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.write(nextResponse.body);
|
||||
nextResponse = null;
|
||||
return;
|
||||
}
|
||||
response.setStatusLine(request.httpVersion, 201, "Created");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.write(
|
||||
JSON.stringify({
|
||||
id: SHARE_ID,
|
||||
name: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: null,
|
||||
size: 10,
|
||||
url: `/api/shares/${SHARE_ID}`,
|
||||
webUrl: `/space/${SHARE_ID}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
server.registerPrefixHandler("/api/shares/", (request, response) => {
|
||||
if (request.path.endsWith("/GONE-GONE-GONE-GONE")) {
|
||||
response.setStatusLine(request.httpVersion, 404, "Not Found");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.write(JSON.stringify({ error: "share not found" }));
|
||||
return;
|
||||
}
|
||||
response.setStatusLine(request.httpVersion, 200, "OK");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.write(
|
||||
JSON.stringify({
|
||||
id: SHARE_ID,
|
||||
name: "John Zen",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: null,
|
||||
size: 10,
|
||||
data: request.path.endsWith("/BADD-BADD-BADD-BADD")
|
||||
? { shared: [] }
|
||||
: VALID_DOC,
|
||||
})
|
||||
);
|
||||
});
|
||||
server.start(-1);
|
||||
base = `http://localhost:${server.identity.primaryPort}`;
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["zen.share.base-url", base]],
|
||||
});
|
||||
registerCleanupFunction(() => new Promise(resolve => server.stop(resolve)));
|
||||
});
|
||||
|
||||
add_task(async function test_create_share() {
|
||||
const created = await ZenShareClient.createShare(VALID_DOC, {
|
||||
name: "John Zen",
|
||||
});
|
||||
Assert.equal(lastRequest.secretKey, null, "no secret key by default");
|
||||
Assert.deepEqual(
|
||||
JSON.parse(lastRequest.body),
|
||||
VALID_DOC,
|
||||
"the body is the bare document"
|
||||
);
|
||||
Assert.equal(
|
||||
created.link,
|
||||
`${base}/space/${SHARE_ID}`,
|
||||
"link is the absolute public page url"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_secret_key_replaces_api_key() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["zen.share.secret-key", "shhh"]],
|
||||
});
|
||||
await ZenShareClient.createShare(VALID_DOC);
|
||||
Assert.equal(lastRequest.secretKey, "shhh", "secret key is sent");
|
||||
Assert.equal(
|
||||
lastRequest.apiKey,
|
||||
null,
|
||||
"the api key is never sent next to a secret key"
|
||||
);
|
||||
await SpecialPowers.popPrefEnv();
|
||||
});
|
||||
|
||||
add_task(async function test_invalid_document_never_uploads() {
|
||||
lastRequest = null;
|
||||
await Assert.rejects(
|
||||
ZenShareClient.createShare({ shared: [] }),
|
||||
e => e.code === "invalid-document",
|
||||
"an array shared value fails the schema"
|
||||
);
|
||||
await Assert.rejects(
|
||||
ZenShareClient.createShare({
|
||||
shared: { type: "space", name: "x", items: [], extra: true },
|
||||
}),
|
||||
e => e.code === "invalid-document",
|
||||
"additionalProperties are rejected locally"
|
||||
);
|
||||
Assert.equal(lastRequest, null, "invalid documents never hit the server");
|
||||
});
|
||||
|
||||
add_task(async function test_fetch_share_preview() {
|
||||
const preview = await ZenShareClient.fetchSharePreview({ id: SHARE_ID });
|
||||
Assert.equal(preview.name, "John Zen", "the sharer name comes back");
|
||||
Assert.deepEqual(preview.doc, VALID_DOC, "the document comes back");
|
||||
|
||||
await Assert.rejects(
|
||||
ZenShareClient.fetchSharePreview({ id: "GONE-GONE-GONE-GONE" }),
|
||||
e => e.code === "not-found",
|
||||
"dead shares map to not-found"
|
||||
);
|
||||
await Assert.rejects(
|
||||
ZenShareClient.fetchSharePreview({ id: "BADD-BADD-BADD-BADD" }),
|
||||
e => e.code === "invalid-document",
|
||||
"downloaded documents are validated too"
|
||||
);
|
||||
});
|
||||
200
src/zen/tests/share/browser_share_import.js
Normal file
200
src/zen/tests/share/browser_share_import.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let initialWorkspace;
|
||||
|
||||
const SPACE_DOC = {
|
||||
version: "1",
|
||||
shared: {
|
||||
type: "space",
|
||||
name: "Imported Space",
|
||||
theme: {
|
||||
type: "gradient",
|
||||
gradientColors: [
|
||||
{ c: [217, 105, 24], type: "explicit", isPrimary: true },
|
||||
{ c: [16, 32, 48], type: "custom", isCustom: true },
|
||||
],
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "folder",
|
||||
name: "Research",
|
||||
icon: "chrome://browser/skin/zen-icons/selectable/star.svg",
|
||||
items: [
|
||||
{ type: "tab", url: "https://example.com/sub", label: "Sub tab" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tab",
|
||||
url: "https://example.com/pinned",
|
||||
label: "Pinned tab",
|
||||
isPinned: true,
|
||||
},
|
||||
{
|
||||
type: "splitView",
|
||||
tabs: [
|
||||
{
|
||||
type: "tab",
|
||||
url: "https://example.com/left",
|
||||
label: "Left",
|
||||
isPinned: true,
|
||||
},
|
||||
{
|
||||
type: "tab",
|
||||
url: "https://example.com/right",
|
||||
label: "Right",
|
||||
isPinned: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "tab", url: "https://example.com/loose", label: "Loose" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
add_setup(async function () {
|
||||
initialWorkspace = gZenWorkspaces.activeWorkspace;
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["zen.view.show-newtab-button-top", true]],
|
||||
});
|
||||
registerCleanupFunction(() => cleanupSpaces(initialWorkspace));
|
||||
});
|
||||
|
||||
add_task(async function test_overlay_preview() {
|
||||
const restore = stubSharePreview(SPACE_DOC, "John Zen");
|
||||
const { tab, overlay } = await openShareOverlay(
|
||||
`${SHARE_BASE}/space/${SHARE_ID}`
|
||||
);
|
||||
|
||||
Assert.ok(
|
||||
tab.linkedBrowser.hasAttribute("zen-share-overlay-showing"),
|
||||
"the share page is visually hidden behind the overlay"
|
||||
);
|
||||
Assert.equal(
|
||||
overlay.querySelector(".zen-share-overlay-badge-title").textContent,
|
||||
"Imported Space"
|
||||
);
|
||||
Assert.ok(
|
||||
tab.hasAttribute("zen-show-sublabel"),
|
||||
"the host tab gets the shared-by sublabel"
|
||||
);
|
||||
|
||||
// Navigating away tears the overlay down and clears the sublabel.
|
||||
const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
|
||||
BrowserTestUtils.startLoadingURIString(
|
||||
tab.linkedBrowser,
|
||||
"https://example.com/"
|
||||
);
|
||||
await loaded;
|
||||
await TestUtils.waitForCondition(
|
||||
() => !tab.linkedBrowser._zenShareOverlay,
|
||||
"overlay is removed on navigation"
|
||||
);
|
||||
Assert.ok(
|
||||
!tab.hasAttribute("zen-show-sublabel"),
|
||||
"the sublabel is cleared on navigation"
|
||||
);
|
||||
|
||||
BrowserTestUtils.removeTab(tab);
|
||||
restore();
|
||||
});
|
||||
|
||||
add_task(async function test_import_space() {
|
||||
const restore = stubSharePreview(SPACE_DOC, "John Zen");
|
||||
let sweepPlayed = false;
|
||||
const restoreSweep = stubMethod(
|
||||
gZenStartup,
|
||||
"playWindowSweepAnimation",
|
||||
() => (sweepPlayed = true)
|
||||
);
|
||||
|
||||
const { tab, overlay } = await openShareOverlay(
|
||||
`${SHARE_BASE}/space/${SHARE_ID}`
|
||||
);
|
||||
await importFromOverlay(tab, overlay);
|
||||
await TestUtils.waitForCondition(
|
||||
() => gZenWorkspaces.activeWorkspace !== initialWorkspace,
|
||||
"the imported space becomes active"
|
||||
);
|
||||
|
||||
const workspace = gZenWorkspaces.getActiveWorkspaceFromCache();
|
||||
Assert.equal(workspace.name, "Imported Space", "the shared name wins");
|
||||
Assert.deepEqual(
|
||||
workspace.theme.gradientColors[0].c,
|
||||
[217, 105, 24],
|
||||
"the shared theme is applied"
|
||||
);
|
||||
Assert.equal(
|
||||
workspace.theme.gradientColors[1].c,
|
||||
"rgb(16, 32, 48)",
|
||||
"custom colors go back to css strings locally"
|
||||
);
|
||||
Assert.ok(sweepPlayed, "importing a space plays the startup sweep");
|
||||
|
||||
const element = gZenWorkspaces.workspaceElement(workspace.uuid);
|
||||
const pinnedKinds = [...element.pinnedTabsContainer.children]
|
||||
.map(child => {
|
||||
if (child.matches?.("zen-folder")) {
|
||||
return "folder:" + child.label;
|
||||
}
|
||||
if (child.matches?.("tab-group[split-view-group]")) {
|
||||
return "split";
|
||||
}
|
||||
return gBrowser.isTab(child) ? "tab:" + child.label : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
Assert.deepEqual(
|
||||
pinnedKinds,
|
||||
["folder:Research", "tab:Pinned tab", "split"],
|
||||
"pinned items import in document order despite the new-tab-top pref"
|
||||
);
|
||||
|
||||
const folder = element.querySelector("zen-folder");
|
||||
Assert.ok(folder.collapsed, "imported folders start collapsed");
|
||||
Assert.equal(
|
||||
folder.iconURL,
|
||||
"chrome://browser/skin/zen-icons/selectable/star.svg",
|
||||
"the folder icon is restored"
|
||||
);
|
||||
|
||||
const split = element.pinnedTabsContainer.querySelector(
|
||||
"tab-group[split-view-group]"
|
||||
);
|
||||
Assert.ok(
|
||||
split.tabs.every(t => t.pinned),
|
||||
"an all-pinned shared split imports pinned"
|
||||
);
|
||||
|
||||
const loose = [...element.tabsContainer.children].find(
|
||||
child => gBrowser.isTab(child) && child.label === "Loose"
|
||||
);
|
||||
Assert.ok(loose, "unpinned tabs land in the normal section");
|
||||
Assert.ok(!loose.pinned, "and stay unpinned");
|
||||
|
||||
restoreSweep();
|
||||
restore();
|
||||
await cleanupSpaces(initialWorkspace);
|
||||
});
|
||||
|
||||
add_task(async function test_dead_link_shows_error() {
|
||||
const restore = stubMethod(ZenShareClient, "fetchSharePreview", async () => {
|
||||
throw new ZenShareError("not-found", "share not found");
|
||||
});
|
||||
const tab = gBrowser.addTrustedTab(`${SHARE_BASE}/space/${SHARE_ID}`, {
|
||||
inBackground: false,
|
||||
});
|
||||
gBrowser.selectedTab = tab;
|
||||
await TestUtils.waitForCondition(() => {
|
||||
const status = tab.linkedBrowser._zenShareOverlay?.querySelector(
|
||||
".zen-share-overlay-status"
|
||||
);
|
||||
return (
|
||||
status?.getAttribute("data-l10n-id") ===
|
||||
"zen-share-import-error-dead-description"
|
||||
);
|
||||
}, "the dead-link message shows in the overlay");
|
||||
BrowserTestUtils.removeTab(tab);
|
||||
restore();
|
||||
});
|
||||
129
src/zen/tests/share/browser_share_serialize.js
Normal file
129
src/zen/tests/share/browser_share_serialize.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let initialWorkspace;
|
||||
|
||||
function captureShare(action) {
|
||||
return new Promise(resolve => {
|
||||
const restore = stubMethod(ZenShareClient, "createShare", async doc => {
|
||||
restore();
|
||||
resolve(doc);
|
||||
return {
|
||||
id: SHARE_ID,
|
||||
expiresAt: null,
|
||||
webUrl: `/space/${SHARE_ID}`,
|
||||
link: `${SHARE_BASE}/space/${SHARE_ID}`,
|
||||
};
|
||||
});
|
||||
action();
|
||||
});
|
||||
}
|
||||
|
||||
add_setup(async function () {
|
||||
initialWorkspace = gZenWorkspaces.activeWorkspace;
|
||||
registerCleanupFunction(() => cleanupSpaces(initialWorkspace));
|
||||
});
|
||||
|
||||
add_task(async function test_space_serialization() {
|
||||
await gZenWorkspaces.createAndSaveWorkspace("Serialize Me");
|
||||
const uuid = gZenWorkspaces.activeWorkspace;
|
||||
|
||||
const folderTab = makeLazyTab("https://folder.example.com/", "Folder tab");
|
||||
const folder = gZenFolders.createFolder([folderTab], { label: "Docs" });
|
||||
gZenFolders.setFolderUserIcon(
|
||||
folder,
|
||||
"chrome://browser/skin/zen-icons/selectable/star.svg"
|
||||
);
|
||||
const pinnedTab = makeLazyTab("https://pinned.example.com/", "Pinned");
|
||||
gBrowser.pinTab(pinnedTab);
|
||||
const splitA = makeLazyTab("https://a.example.com/", "Split A");
|
||||
const splitB = makeLazyTab("https://b.example.com/", "Split B");
|
||||
gBrowser.pinTab(splitA);
|
||||
gBrowser.pinTab(splitB);
|
||||
gZenViewSplitter.splitTabs([splitA, splitB], "grid", -1);
|
||||
// Normal section: a plain tab and one that must not be shared.
|
||||
makeLazyTab("https://normal.example.com/", "Normal");
|
||||
makeLazyTab("about:preferences", "Internal");
|
||||
|
||||
const workspace = gZenWorkspaces.getActiveWorkspaceFromCache();
|
||||
workspace.theme = {
|
||||
type: "gradient",
|
||||
opacity: 0.7,
|
||||
texture: 0.3,
|
||||
gradientColors: [
|
||||
{ c: [10, 20, 30], type: "explicit", isPrimary: true, lightness: "50" },
|
||||
{ c: "#102030", isCustom: true, type: "custom" },
|
||||
{ c: "color-mix(in srgb, red, blue)", isCustom: true, type: "custom" },
|
||||
],
|
||||
};
|
||||
|
||||
const doc = await captureShare(() => gZenShareManager.shareSpace(uuid));
|
||||
const item = doc.shared;
|
||||
|
||||
Assert.equal(item.type, "space");
|
||||
Assert.equal(item.name, "Serialize Me");
|
||||
Assert.deepEqual(
|
||||
item.items.map(i => i.type),
|
||||
["folder", "tab", "splitView", "tab"],
|
||||
"pinned items come first, in strip order; internal urls are dropped"
|
||||
);
|
||||
|
||||
const [folderItem, pinnedItem, splitItem, normalItem] = item.items;
|
||||
Assert.equal(folderItem.name, "Docs");
|
||||
Assert.equal(
|
||||
folderItem.icon,
|
||||
"chrome://browser/skin/zen-icons/selectable/star.svg",
|
||||
"the custom folder icon is shared"
|
||||
);
|
||||
Assert.equal(folderItem.items.length, 1, "folder keeps its tab");
|
||||
Assert.ok(pinnedItem.isPinned, "loose pinned tabs carry isPinned");
|
||||
Assert.ok(
|
||||
splitItem.tabs.every(t => t.isPinned),
|
||||
"split members carry their real pinned state"
|
||||
);
|
||||
Assert.ok(!normalItem.isPinned, "normal tabs carry no pinned flag");
|
||||
|
||||
const theme = item.theme;
|
||||
Assert.ok(!("opacity" in theme), "theme opacity is stripped");
|
||||
Assert.ok(!("texture" in theme), "theme texture is stripped");
|
||||
Assert.equal(
|
||||
theme.gradientColors.length,
|
||||
2,
|
||||
"unparseable custom colors are dropped"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
theme.gradientColors[1].c,
|
||||
[16, 32, 48],
|
||||
"custom css color strings become [r, g, b]"
|
||||
);
|
||||
|
||||
const validation = await ZenShareClient.validateDocument(doc);
|
||||
Assert.ok(
|
||||
validation.valid,
|
||||
"the serialized document passes the schema: " +
|
||||
JSON.stringify(validation.errors ?? [])
|
||||
);
|
||||
|
||||
await cleanupSpaces(initialWorkspace);
|
||||
});
|
||||
|
||||
add_task(async function test_share_split_view_requires_two_tabs() {
|
||||
let created = false;
|
||||
const restore = stubMethod(ZenShareClient, "createShare", async () => {
|
||||
created = true;
|
||||
return { expiresAt: null, webUrl: "/x", link: "x" };
|
||||
});
|
||||
const restoreToast = stubMethod(gZenUIManager, "showToast", () => {});
|
||||
const tab = makeLazyTab("https://only.example.com/", "Only");
|
||||
const fakeGroup = {
|
||||
hasAttribute: name => name === "split-view-group",
|
||||
tabs: [tab],
|
||||
};
|
||||
await gZenShareManager.shareSplitView(fakeGroup);
|
||||
Assert.ok(!created, "a split with fewer than two tabs is not shared");
|
||||
restore();
|
||||
restoreToast();
|
||||
BrowserTestUtils.removeTab(tab);
|
||||
});
|
||||
112
src/zen/tests/share/browser_share_split_link.js
Normal file
112
src/zen/tests/share/browser_share_split_link.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let initialWorkspace;
|
||||
|
||||
function splitDoc(pinned) {
|
||||
return {
|
||||
shared: {
|
||||
type: "splitView",
|
||||
tabs: [
|
||||
{
|
||||
type: "tab",
|
||||
url: "https://example.com/one",
|
||||
label: "One",
|
||||
...(pinned && { isPinned: true }),
|
||||
},
|
||||
{
|
||||
type: "tab",
|
||||
url: "https://example.com/two",
|
||||
label: "Two",
|
||||
...(pinned && { isPinned: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function waitForSplitGroups(count) {
|
||||
return TestUtils.waitForCondition(
|
||||
() =>
|
||||
document.querySelectorAll("tab-group[split-view-group]").length >= count,
|
||||
`${count} split group(s) should exist`
|
||||
);
|
||||
}
|
||||
|
||||
add_setup(async function () {
|
||||
initialWorkspace = gZenWorkspaces.activeWorkspace;
|
||||
registerCleanupFunction(() => cleanupSpaces(initialWorkspace));
|
||||
});
|
||||
|
||||
add_task(async function test_pinned_split_link_opens_directly() {
|
||||
const restore = stubSharePreview(splitDoc(true));
|
||||
const shareTab = gBrowser.addTrustedTab(`${SHARE_BASE}/split/${SHARE_ID}`, {
|
||||
inBackground: false,
|
||||
});
|
||||
gBrowser.selectedTab = shareTab;
|
||||
|
||||
await waitForSplitGroups(1);
|
||||
await TestUtils.waitForCondition(
|
||||
() => !gBrowser.tabs.includes(shareTab),
|
||||
"the share tab closes once the split opens"
|
||||
);
|
||||
|
||||
const group = document.querySelector("tab-group[split-view-group]");
|
||||
Assert.ok(gZenViewSplitter.splitViewActive, "the split view is active");
|
||||
Assert.ok(
|
||||
group.tabs.every(t => t.pinned),
|
||||
"an all-pinned shared split opens pinned"
|
||||
);
|
||||
Assert.ok(
|
||||
group.closest(".zen-workspace-pinned-tabs-section"),
|
||||
"and lives in the pinned section"
|
||||
);
|
||||
|
||||
restore();
|
||||
await cleanupSpaces(initialWorkspace);
|
||||
});
|
||||
|
||||
add_task(async function test_unpinned_split_link_stays_normal() {
|
||||
const restore = stubSharePreview(splitDoc(false));
|
||||
const shareTab = gBrowser.addTrustedTab(`${SHARE_BASE}/split/${SHARE_ID}`, {
|
||||
inBackground: false,
|
||||
});
|
||||
gBrowser.selectedTab = shareTab;
|
||||
|
||||
await waitForSplitGroups(1);
|
||||
const group = document.querySelector("tab-group[split-view-group]");
|
||||
Assert.ok(
|
||||
group.tabs.every(t => !t.pinned),
|
||||
"an unpinned shared split opens unpinned"
|
||||
);
|
||||
Assert.ok(
|
||||
group.closest(".zen-workspace-normal-tabs-section"),
|
||||
"and lives in the normal section"
|
||||
);
|
||||
|
||||
restore();
|
||||
await cleanupSpaces(initialWorkspace);
|
||||
});
|
||||
|
||||
add_task(async function test_failed_split_link_restores_tab() {
|
||||
const restore = stubMethod(ZenShareClient, "fetchSharePreview", async () => {
|
||||
throw new ZenShareError("not-found", "share not found");
|
||||
});
|
||||
const restoreToast = stubMethod(gZenUIManager, "showToast", () => {});
|
||||
const shareTab = gBrowser.addTrustedTab(`${SHARE_BASE}/split/${SHARE_ID}`, {
|
||||
inBackground: false,
|
||||
});
|
||||
gBrowser.selectedTab = shareTab;
|
||||
|
||||
await TestUtils.waitForCondition(
|
||||
() => gBrowser.selectedTab === shareTab && !shareTab.hidden,
|
||||
"the share tab is shown again when the split fails to load"
|
||||
);
|
||||
Assert.ok(gBrowser.tabs.includes(shareTab), "the tab is not closed");
|
||||
|
||||
restoreToast();
|
||||
restore();
|
||||
BrowserTestUtils.removeTab(shareTab);
|
||||
});
|
||||
56
src/zen/tests/share/browser_share_url_parsing.js
Normal file
56
src/zen/tests/share/browser_share_url_parsing.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
https://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
add_task(async function test_slugs_and_types() {
|
||||
for (const [slug, type] of [
|
||||
["space", "space"],
|
||||
["folder", "folder"],
|
||||
["split-view", "split-view"],
|
||||
// "split" is the canonical server slug for split views.
|
||||
["split", "split-view"],
|
||||
]) {
|
||||
const share = ZenShareClient.parseShareUrl(
|
||||
`${SHARE_BASE}/${slug}/${SHARE_ID}`
|
||||
);
|
||||
Assert.equal(share?.type, type, `${slug} maps to ${type}`);
|
||||
Assert.equal(share?.id, SHARE_ID, `${slug} keeps the id`);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(async function test_id_normalization() {
|
||||
const share = ZenShareClient.parseShareUrl(
|
||||
`${SHARE_BASE}/space/aaaa-bbbb-cccc-dddd`
|
||||
);
|
||||
Assert.equal(share?.id, SHARE_ID, "ids are matched case-insensitively");
|
||||
});
|
||||
|
||||
add_task(async function test_query_and_fragment_tolerated() {
|
||||
Assert.ok(
|
||||
ZenShareClient.parseShareUrl(`${SHARE_BASE}/space/${SHARE_ID}/`),
|
||||
"trailing slash is accepted"
|
||||
);
|
||||
Assert.ok(
|
||||
ZenShareClient.parseShareUrl(`${SHARE_BASE}/space/${SHARE_ID}?utm=x`),
|
||||
"query strings are ignored"
|
||||
);
|
||||
Assert.ok(
|
||||
ZenShareClient.parseShareUrl(`${SHARE_BASE}/space/${SHARE_ID}#frag`),
|
||||
"fragments are ignored"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_rejections() {
|
||||
for (const spec of [
|
||||
`https://not-the-server.com/space/${SHARE_ID}`,
|
||||
`${SHARE_BASE}/space/aRO9GBjhTOTZ`, // the old 12-char id format
|
||||
`${SHARE_BASE}/space/AAAA-BBBB-CCCC`, // too few groups
|
||||
`${SHARE_BASE}/nonsense/${SHARE_ID}`,
|
||||
`${SHARE_BASE}/space/${SHARE_ID}/extra`,
|
||||
`${SHARE_BASE}/space/`,
|
||||
"not a url",
|
||||
]) {
|
||||
Assert.equal(ZenShareClient.parseShareUrl(spec), null, `rejects ${spec}`);
|
||||
}
|
||||
});
|
||||
88
src/zen/tests/share/head.js
Normal file
88
src/zen/tests/share/head.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/* 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/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { ZenShareClient, ZenShareError } = ChromeUtils.importESModule(
|
||||
"resource:///modules/zen/share/ZenShareClient.sys.mjs"
|
||||
);
|
||||
|
||||
const SHARE_BASE = "https://example.com";
|
||||
const SHARE_ID = "AAAA-BBBB-CCCC-DDDD";
|
||||
|
||||
/**
|
||||
* Replaces a method on an object until the current test file ends.
|
||||
*
|
||||
* @returns {Function} Restores the original immediately.
|
||||
*/
|
||||
function stubMethod(object, name, replacement) {
|
||||
const original = object[name];
|
||||
object[name] = replacement;
|
||||
const restore = () => (object[name] = original);
|
||||
registerCleanupFunction(restore);
|
||||
return restore;
|
||||
}
|
||||
|
||||
function stubSharePreview(doc, name = null) {
|
||||
return stubMethod(ZenShareClient, "fetchSharePreview", async () => ({
|
||||
doc,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeLazyTab(url, label) {
|
||||
return gBrowser.addTrustedTab(url, {
|
||||
createLazyBrowser: true,
|
||||
inBackground: true,
|
||||
skipAnimation: true,
|
||||
lazyTabTitle: label,
|
||||
skipRoute: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens a share link and waits for its overlay to finish loading. */
|
||||
async function openShareOverlay(spec) {
|
||||
const tab = gBrowser.addTrustedTab(spec, { inBackground: false });
|
||||
gBrowser.selectedTab = tab;
|
||||
await TestUtils.waitForCondition(() => {
|
||||
const overlay = tab.linkedBrowser._zenShareOverlay;
|
||||
return overlay && !overlay.querySelector(".zen-share-overlay-status");
|
||||
}, "share overlay should finish loading");
|
||||
return { tab, overlay: tab.linkedBrowser._zenShareOverlay };
|
||||
}
|
||||
|
||||
/** Clicks the overlay's import button and waits for the tab to close. */
|
||||
async function importFromOverlay(tab, overlay) {
|
||||
const closed = BrowserTestUtils.waitForTabClosing(tab);
|
||||
overlay.querySelector(".zen-share-overlay-add").click();
|
||||
await closed;
|
||||
}
|
||||
|
||||
/** Removes every workspace except the one passed, and all leftover tabs,
|
||||
* folders and splits of the surviving workspace. */
|
||||
async function cleanupSpaces(keepUuid) {
|
||||
for (const workspace of gZenWorkspaces.getWorkspaces()) {
|
||||
if (workspace.uuid !== keepUuid) {
|
||||
await gZenWorkspaces.removeWorkspace(workspace.uuid);
|
||||
}
|
||||
}
|
||||
const element = gZenWorkspaces.workspaceElement(keepUuid);
|
||||
for (const folder of [...element.querySelectorAll("zen-folder")]) {
|
||||
await folder.delete();
|
||||
}
|
||||
// A fresh keeper tab lets every test tab go, including the last pinned
|
||||
// split member that would otherwise survive and leak into the next task.
|
||||
const keeper = BrowserTestUtils.addTab(gBrowser, "about:blank", {
|
||||
skipAnimation: true,
|
||||
});
|
||||
for (const tab of [...gBrowser.tabs]) {
|
||||
if (tab !== keeper && !tab.hasAttribute("zen-empty-tab")) {
|
||||
BrowserTestUtils.removeTab(tab);
|
||||
}
|
||||
}
|
||||
await TestUtils.waitForCondition(
|
||||
() => !document.querySelector("tab-group[split-view-group]"),
|
||||
"no split groups should survive cleanup"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user