gh-15266: Add support for sharing spaces, folders and split views (gh-15283)

This commit is contained in:
mr. m
2026-09-07 22:28:09 +02:00
committed by GitHub
parent a323036995
commit 7a78848177
19 changed files with 838 additions and 44 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
}
});

View File

@@ -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,

View File

@@ -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 }
);
}

View File

@@ -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;
}
}

View File

@@ -7,11 +7,7 @@
"type": "string"
},
"shared": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/shareable"
}
"$ref": "#/$defs/shareable"
}
},
"required": ["shared"],

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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",

View 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"]

View 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"
);
});

View 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();
});

View 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);
});

View 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);
});

View 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}`);
}
});

View 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"
);
}