diff --git a/locales/en-US/browser/browser/zen-share.ftl b/locales/en-US/browser/browser/zen-share.ftl index 5df7e87c6..e562e3b56 100644 --- a/locales/en-US/browser/browser/zen-share.ftl +++ b/locales/en-US/browser/browser/zen-share.ftl @@ -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. diff --git a/src/browser/components/preferences/zen-settings.js b/src/browser/components/preferences/zen-settings.js index 83f2fc3c4..90151667c 100644 --- a/src/browser/components/preferences/zen-settings.js +++ b/src/browser/components/preferences/zen-settings.js @@ -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); diff --git a/src/zen/boosts/ZenBoostsManager.sys.mjs b/src/zen/boosts/ZenBoostsManager.sys.mjs index 0a84833d1..4878ae8fd 100644 --- a/src/zen/boosts/ZenBoostsManager.sys.mjs +++ b/src/zen/boosts/ZenBoostsManager.sys.mjs @@ -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; diff --git a/src/zen/common/modules/ZenUIManager.mjs b/src/zen/common/modules/ZenUIManager.mjs index 770ffbb22..935e82a43 100644 --- a/src/zen/common/modules/ZenUIManager.mjs +++ b/src/zen/common/modules/ZenUIManager.mjs @@ -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(); } }); diff --git a/src/zen/common/styles/squircle-exclusions.inc.css b/src/zen/common/styles/squircle-exclusions.inc.css index f26710dc2..f69f6c2d5 100644 --- a/src/zen/common/styles/squircle-exclusions.inc.css +++ b/src/zen/common/styles/squircle-exclusions.inc.css @@ -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, diff --git a/src/zen/mods/ZenMods.mjs b/src/zen/mods/ZenMods.mjs index 0fa521b1d..11c002bfe 100644 --- a/src/zen/mods/ZenMods.mjs +++ b/src/zen/mods/ZenMods.mjs @@ -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 } ); } diff --git a/src/zen/share/ZenShareManager.mjs b/src/zen/share/ZenShareManager.mjs index 3a8f9a76d..3c2bad288 100644 --- a/src/zen/share/ZenShareManager.mjs +++ b/src/zen/share/ZenShareManager.mjs @@ -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; } } diff --git a/src/zen/share/share.schema.json b/src/zen/share/share.schema.json index f8c99f301..b52b03804 100644 --- a/src/zen/share/share.schema.json +++ b/src/zen/share/share.schema.json @@ -7,11 +7,7 @@ "type": "string" }, "shared": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/shareable" - } + "$ref": "#/$defs/shareable" } }, "required": ["shared"], diff --git a/src/zen/share/zen-share-confirm.inc.xhtml b/src/zen/share/zen-share-confirm.inc.xhtml index 07b042bc7..87afe6d4c 100644 --- a/src/zen/share/zen-share-confirm.inc.xhtml +++ b/src/zen/share/zen-share-confirm.inc.xhtml @@ -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" > - + @@ -47,6 +45,7 @@ data-l10n-id="zen-share-confirm-anonymous" /> { + 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" + ); +}); diff --git a/src/zen/tests/share/browser_share_import.js b/src/zen/tests/share/browser_share_import.js new file mode 100644 index 000000000..5f2400252 --- /dev/null +++ b/src/zen/tests/share/browser_share_import.js @@ -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(); +}); diff --git a/src/zen/tests/share/browser_share_serialize.js b/src/zen/tests/share/browser_share_serialize.js new file mode 100644 index 000000000..88b1bb77b --- /dev/null +++ b/src/zen/tests/share/browser_share_serialize.js @@ -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); +}); diff --git a/src/zen/tests/share/browser_share_split_link.js b/src/zen/tests/share/browser_share_split_link.js new file mode 100644 index 000000000..1a40f31e3 --- /dev/null +++ b/src/zen/tests/share/browser_share_split_link.js @@ -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); +}); diff --git a/src/zen/tests/share/browser_share_url_parsing.js b/src/zen/tests/share/browser_share_url_parsing.js new file mode 100644 index 000000000..776fdc193 --- /dev/null +++ b/src/zen/tests/share/browser_share_url_parsing.js @@ -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}`); + } +}); diff --git a/src/zen/tests/share/head.js b/src/zen/tests/share/head.js new file mode 100644 index 000000000..82a96f288 --- /dev/null +++ b/src/zen/tests/share/head.js @@ -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" + ); +}