diff --git a/frontend/src/app.js b/frontend/src/app.js index 33e4b9cb6..2fc0a3c1e 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -34,7 +34,6 @@ import $event from "common/event"; import $log from "common/log"; import { registerServiceWorker } from "common/pwa"; import $util from "common/util"; -import { recordInstanceFromConfig } from "common/instances"; import * as components from "component/components"; import icons from "component/icons"; import defaults from "component/defaults"; @@ -77,10 +76,6 @@ $config.update().finally(() => { // Check if running in public mode. const $isPublic = $config.isPublic(); - // Record this instance in the shared browser-storage directory so same-origin - // siblings can offer it as an instance-switcher target (see common/instances). - recordInstanceFromConfig($config); - let app = createApp(PhotoPrism); // Initialize language and detect its alignment. diff --git a/frontend/src/common/instances.js b/frontend/src/common/instances.js index a5b60e05d..a67979fc6 100644 --- a/frontend/src/common/instances.js +++ b/frontend/src/common/instances.js @@ -23,67 +23,29 @@ Additional information can be found in our Developer Guide: */ -// Shared, cross-namespace browser-storage directory that lets same-origin -// PhotoPrism instances discover one another for the account-menu instance -// switcher. Each instance records its own {siteUrl, name} under its storage -// namespace (the SHA-256 of its SiteUrl, see config StorageNamespace and -// assets/templates/auth.gohtml). The switcher then renders the directory -// entries that currently hold a live session token (common/storage.js -// listAuthSessions) so a user can jump between instances they are signed in to, -// without relying on any cluster API. On standalone or separate-origin -// instances the directory only ever contains the current instance, so the -// switcher stays hidden. +import { listAuthSessions, buildNamespace } from "common/storage"; -import { listAuthSessions } from "common/storage"; +// Storage suffixes under which each instance records its public identity, so +// peers on the same shared-domain origin can render a navigation switcher entry. +// The namespace is a SHA-256 hash of the SiteUrl and is not reversible, so the +// URL and title must be persisted explicitly under the instance's namespace. +const InstanceUrlKey = "instance.url"; +const InstanceTitleKey = "instance.title"; -// DirectoryKey is the raw localStorage key holding the instance directory. It -// is intentionally NOT namespaced (no "pp::" prefix) so every same-origin -// instance reads and writes the same map; parseNamespace() ignores it because -// it has no second separator. -export const DirectoryKey = "pp:instances"; +// InstanceIdentityKeys lists the suffix keys written by persistInstanceIdentity, +// so callers (e.g. session logout) can clear them across storage backends. +export const InstanceIdentityKeys = [InstanceUrlKey, InstanceTitleKey]; -// getWindow safely accesses the browser window if it exists. -const getWindow = () => (typeof window === "undefined" ? null : window); - -// getLocalStorage returns the explicit storage or the raw window localStorage. -// The directory deliberately uses raw (un-namespaced) storage so it is shared -// across instance namespaces on the same origin. -const getLocalStorage = (storage) => { - if (storage) { - return storage; - } - const w = getWindow(); - return w ? w.localStorage : null; -}; - -// readConfigValue reads a string field from a Config-like object, tolerating -// both the live $config (with .values / .get()) and a plain values object. -const readConfigValue = (config, key) => { - if (!config) { - return ""; - } - if (config.values && typeof config.values[key] === "string") { - return config.values[key]; - } - if (typeof config.get === "function") { - const v = config.get(key); - if (typeof v === "string") { - return v; - } - } - if (typeof config[key] === "string") { - return config[key]; - } - return ""; -}; +// safeWindow returns the browser window if available, else null. +const safeWindow = () => (typeof window === "undefined" ? null : window); // instanceLabel derives a short, distinctive display name from a SiteUrl — the // last base-path segment (e.g. "pro-1" for ".../i/pro-1/"). The switcher can // only surface same-origin instances, which always differ by path, so the path // segment is more distinctive than the frequently-generic site caption/title // (multiple instances commonly share the default "PhotoPrism" caption). Returns -// "" for a root-path or unparseable URL so the caller falls back to the caption. -const instanceLabel = (siteUrl) => { +// "" for a root-path or unparseable URL so the caller falls back to the title. +export function instanceLabel(siteUrl) { if (!siteUrl || typeof siteUrl !== "string") { return ""; } @@ -93,95 +55,78 @@ const instanceLabel = (siteUrl) => { } catch { return ""; } -}; +} -// instanceIdentity extracts {namespace, siteUrl, name} from a Config-like -// object. The name prefers the distinctive base-path segment, then the site -// caption/title/app name, then the SiteUrl so an entry is always labelable. -export const instanceIdentity = (config) => { - const namespace = readConfigValue(config, "storageNamespace"); - const siteUrl = readConfigValue(config, "siteUrl"); - const name = - instanceLabel(siteUrl) || - readConfigValue(config, "siteCaption") || - readConfigValue(config, "siteTitle") || - readConfigValue(config, "name") || - siteUrl; - return { namespace, siteUrl, name }; -}; - -// readDirectory parses the instance directory map; returns {} on any error. -export const readDirectory = (storage) => { - const store = getLocalStorage(storage); - if (!store || typeof store.getItem !== "function") { - return {}; - } - try { - const raw = store.getItem(DirectoryKey); - if (!raw) { - return {}; - } - const dir = JSON.parse(raw); - return dir && typeof dir === "object" ? dir : {}; - } catch { - return {}; - } -}; - -// recordInstance upserts an instance identity into the shared directory. A -// missing namespace or siteUrl is a no-op, so it is safe to call during early -// bootstrap before the client config is fully resolved. -export const recordInstance = (identity, storage) => { - const { namespace, siteUrl, name } = identity || {}; - if (!namespace || !siteUrl) { +// persistInstanceIdentity records this instance's SiteUrl and display title in +// the given (namespaced) store, so other instances on the same origin can list +// it in the navigation instance switcher. No-op without a URL or usable store. +export function persistInstanceIdentity(store, identity) { + if (!store || typeof store.setItem !== "function" || !identity || !identity.url) { return; } - const store = getLocalStorage(storage); - if (!store || typeof store.setItem !== "function") { - return; + + store.setItem(InstanceUrlKey, identity.url); + + if (identity.title) { + store.setItem(InstanceTitleKey, identity.title); + } else { + store.removeItem(InstanceTitleKey); } - try { - const dir = readDirectory(store); - const next = name || siteUrl; - const existing = dir[namespace]; - if (existing && existing.siteUrl === siteUrl && existing.name === next) { +} + +// listReachableInstances returns the instances (other than currentNamespace) that +// have a live session token and a recorded identity in shared browser storage, +// for the navigation instance switcher. Both localStorage (persistent sessions) +// and sessionStorage (ephemeral sessions, shared across same-tab navigations) are +// scanned, since recordInstanceIdentity writes to whichever the instance uses. +// Returns an empty array on standalone or subdomain-isolated deployments where no +// peer sessions are discoverable. +export function listReachableInstances(options) { + const opts = options || {}; + + let stores; + if (opts.storage) { + stores = [opts.storage]; + } else if (Array.isArray(opts.stores)) { + stores = opts.stores; + } else { + const w = safeWindow(); + stores = [w?.localStorage, w?.sessionStorage]; + } + + const currentPrefix = buildNamespace(opts.currentNamespace); + const seen = new Set(); + const instances = []; + + stores.forEach((store) => { + if (!store || typeof store.getItem !== "function") { return; } - dir[namespace] = { siteUrl, name: next }; - store.setItem(DirectoryKey, JSON.stringify(dir)); - } catch { - /* ignore quota / serialization errors — the directory is best-effort */ - } -}; -// recordInstanceFromConfig records the current instance using its client config. -export const recordInstanceFromConfig = (config, storage) => { - recordInstance(instanceIdentity(config), storage); -}; + listAuthSessions(store).forEach((session) => { + const namespace = session && session.namespace; + if (!namespace) { + return; + } -// signedInInstances returns the directory entries — excluding the current -// instance — that currently hold a live session token, as the switch-instance -// target list. Each target is {namespace, siteUrl, name}, sorted by name for a -// deterministic menu order. -export const signedInInstances = (config, storage) => { - const store = getLocalStorage(storage); - if (!store) { - return []; - } - const dir = readDirectory(store); - const current = readConfigValue(config, "storageNamespace"); - const live = new Set(listAuthSessions(store).map((s) => s.namespace)); - const out = []; - for (const namespace of Object.keys(dir)) { - if (namespace === current || !live.has(namespace)) { - continue; - } - const entry = dir[namespace]; - if (!entry || !entry.siteUrl) { - continue; - } - out.push({ namespace, siteUrl: entry.siteUrl, name: entry.name || entry.siteUrl }); - } - out.sort((a, b) => a.name.localeCompare(b.name)); - return out; -}; + const prefix = buildNamespace(namespace); + if (prefix === currentPrefix || seen.has(prefix)) { + return; + } + + const url = store.getItem(prefix + InstanceUrlKey); + if (!url) { + return; + } + + seen.add(prefix); + instances.push({ + namespace, + url, + title: store.getItem(prefix + InstanceTitleKey) || url, + }); + }); + }); + + return instances; +} diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js index 95043fe2a..d84d88cc5 100644 --- a/frontend/src/common/session.js +++ b/frontend/src/common/session.js @@ -26,6 +26,7 @@ Additional information can be found in our Developer Guide: import $api from "common/api"; import $event from "common/event"; import { createNamespacedStorage } from "common/storage"; +import { persistInstanceIdentity, InstanceIdentityKeys, instanceLabel } from "common/instances"; import { $view } from "common/view"; import User from "model/user"; import Socket from "websocket.js"; @@ -154,6 +155,11 @@ export default class Session { // Authenticated? this.auth = this.isUser(); + // Record this instance's identity so peers on a shared domain can switch to it. + if (this.auth) { + this.recordInstanceIdentity(); + } + // Subscribe to session events. $event.subscribe("session.logout", () => { return this.onLogout(); @@ -444,6 +450,28 @@ export default class Session { this.user = new User(user); this.storage.setItem(this.storageKey + ".user", JSON.stringify(user)); this.auth = this.isUser(); + + if (this.auth) { + this.recordInstanceIdentity(); + } + } + + // recordInstanceIdentity stores this instance's SiteUrl and display title under + // the active namespace so other instances on the same origin can offer a switch + // entry. The title prefers the distinctive base-path segment (instanceLabel) so + // co-located instances that share a generic caption stay distinguishable. No-op + // when the site URL is unknown or storage is unavailable. + recordInstanceIdentity() { + const values = this.config?.values; + + if (!values || !values.siteUrl) { + return; + } + + persistInstanceIdentity(this.storage, { + url: values.siteUrl, + title: instanceLabel(values.siteUrl) || values.siteTitle || values.name || values.siteUrl, + }); } getUser() { @@ -615,6 +643,9 @@ export default class Session { this.clearNamespacedKey(this.storageKey + ".user"); this.clearLegacyKey("user"); this.clearLegacyKey("session.user"); + // Drop this instance's switcher identity so a logged-out instance is no + // longer offered as a switch target by peers on the same shared domain. + InstanceIdentityKeys.forEach((key) => this.clearNamespacedKey(key)); } deleteClipboard() { diff --git a/frontend/src/component/navigation.vue b/frontend/src/component/navigation.vue index 960b14980..9665f881d 100644 --- a/frontend/src/component/navigation.vue +++ b/frontend/src/component/navigation.vue @@ -641,18 +641,15 @@ @@ -770,11 +767,15 @@ diff --git a/frontend/src/css/navigation.css b/frontend/src/css/navigation.css index 83e3d5580..d62071f47 100644 --- a/frontend/src/css/navigation.css +++ b/frontend/src/css/navigation.css @@ -54,6 +54,33 @@ nav .v-list__item__title.title { margin-right: auto; } +#p-navigation .nav-user-activator { + display: flex; + align-items: center; + flex-grow: 1; + gap: 8px; + min-width: 0; + padding: 2px 4px; + border-radius: 8px; + cursor: pointer; +} + +#p-navigation .nav-user-activator .nav-user-text { + min-width: 0; + overflow: hidden; +} + +#p-navigation .nav-user-activator .nav-user-text p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-sidebar.v-navigation-drawer--rail .nav-container > .nav-info > .nav-user-activator { + flex-grow: 0; + justify-content: center; +} + #p-navigation .nav-sidebar .nav-logo { width: 40px; text-align: inherit; diff --git a/frontend/tests/acceptance/page-model/page.js b/frontend/tests/acceptance/page-model/page.js index bfa6760db..03cf17a47 100644 --- a/frontend/tests/acceptance/page-model/page.js +++ b/frontend/tests/acceptance/page-model/page.js @@ -61,7 +61,13 @@ export default class Page { async logout() { await menu.openNav(); - await t.click(Selector("button i.mdi-power")); + await t.click(Selector("div.nav-user-avatar")); + await t.click(Selector(".nav-user-menu__list .action-logout")); + } + + async openAccount() { + await t.click(Selector("div.nav-user-avatar")); + await t.click(Selector(".nav-user-menu__list .action-settings")); } async clickCardTitleOfUID(uid) { diff --git a/frontend/tests/vitest/common/instances.test.js b/frontend/tests/vitest/common/instances.test.js index 5d18ecc29..f8e25eed0 100644 --- a/frontend/tests/vitest/common/instances.test.js +++ b/frontend/tests/vitest/common/instances.test.js @@ -1,132 +1,128 @@ -import { describe, expect, it } from "vitest"; -import { DirectoryKey, instanceIdentity, readDirectory, recordInstance, recordInstanceFromConfig, signedInInstances } from "common/instances"; +import { describe, it, expect } from "vitest"; +import StorageShim from "node-storage-shim"; +import { buildNamespace, createNamespacedStorage } from "common/storage"; +import { persistInstanceIdentity, listReachableInstances, InstanceIdentityKeys, instanceLabel } from "common/instances"; -// FakeStorage mirrors the subset of the Web Storage API that common/storage and -// common/instances rely on (length/key/getItem/setItem/removeItem), with -// insertion-ordered keys so listAuthSessions can enumerate namespaces. -class FakeStorage { - constructor() { - this.map = new Map(); +// seedInstance writes a peer instance's session token and identity into a shared store. +const seedInstance = (store, namespace, { token = "tok", url, title } = {}) => { + const prefix = buildNamespace(namespace); + if (token) { + store.setItem(prefix + "session.token", token); } - get length() { - return this.map.size; + if (url) { + store.setItem(prefix + "instance.url", url); } - key(i) { - const keys = Array.from(this.map.keys()); - return i >= 0 && i < keys.length ? keys[i] : null; + if (title) { + store.setItem(prefix + "instance.title", title); } - getItem(k) { - return this.map.has(k) ? this.map.get(k) : null; - } - setItem(k, v) { - this.map.set(k, String(v)); - } - removeItem(k) { - this.map.delete(k); - } -} +}; -const NS_PORTAL = "aaaaaaaaaaaa"; -const NS_P1 = "bbbbbbbbbbbb"; -const NS_P2 = "cccccccccccc"; - -const seedDirectory = (store) => - store.setItem( - DirectoryKey, - JSON.stringify({ - [NS_PORTAL]: { siteUrl: "https://x/", name: "Portal" }, - [NS_P1]: { siteUrl: "https://x/i/pro-1", name: "Pro One" }, - [NS_P2]: { siteUrl: "https://x/i/pro-2", name: "Pro Two" }, - }) - ); - -describe("common/instances instanceIdentity", () => { - it("derives the name from the distinctive base-path segment", () => { - const config = { values: { storageNamespace: NS_P1, siteUrl: "https://app.example.com/i/pro-1/", siteCaption: "AI-Powered DAM" } }; - expect(instanceIdentity(config)).toEqual({ namespace: NS_P1, siteUrl: "https://app.example.com/i/pro-1/", name: "pro-1" }); +describe("common/instances", () => { + describe("instanceLabel", () => { + it("derives the last base-path segment as a distinctive label", () => { + expect(instanceLabel("https://app.example.com/i/pro-1/")).toBe("pro-1"); + expect(instanceLabel("https://app.example.com/i/pro-2")).toBe("pro-2"); + }); + it("returns an empty string for a root-path or unparseable url", () => { + expect(instanceLabel("https://app.example.com/")).toBe(""); + expect(instanceLabel("not a url")).toBe(""); + expect(instanceLabel("")).toBe(""); + expect(instanceLabel(null)).toBe(""); + }); }); - it("falls back to the site caption when the URL has no base path", () => { - const config = { values: { storageNamespace: NS_PORTAL, siteUrl: "https://x/", siteCaption: "Portal" } }; - expect(instanceIdentity(config)).toEqual({ namespace: NS_PORTAL, siteUrl: "https://x/", name: "Portal" }); + + describe("persistInstanceIdentity", () => { + it("writes url and title under the namespace", () => { + const store = new StorageShim(); + const ns = createNamespacedStorage(store, "ns-pro-1"); + persistInstanceIdentity(ns, { url: "https://pro-1.example.com/", title: "Pro One" }); + const prefix = buildNamespace("ns-pro-1"); + expect(store.getItem(prefix + "instance.url")).toBe("https://pro-1.example.com/"); + expect(store.getItem(prefix + "instance.title")).toBe("Pro One"); + }); + it("clears a stale title when none is provided", () => { + const store = new StorageShim(); + const ns = createNamespacedStorage(store, "ns-pro-1"); + persistInstanceIdentity(ns, { url: "https://pro-1.example.com/", title: "Pro One" }); + persistInstanceIdentity(ns, { url: "https://pro-1.example.com/" }); + const prefix = buildNamespace("ns-pro-1"); + expect(store.getItem(prefix + "instance.title")).toBeNull(); + }); + it("is a no-op without a url", () => { + const store = new StorageShim(); + const ns = createNamespacedStorage(store, "ns-pro-1"); + persistInstanceIdentity(ns, { title: "Pro One" }); + expect(store.length).toBe(0); + }); + it("is a no-op without a usable store", () => { + expect(() => persistInstanceIdentity(null, { url: "https://pro-1.example.com/" })).not.toThrow(); + }); + it("exposes the identity suffix keys for cleanup", () => { + expect(InstanceIdentityKeys).toContain("instance.url"); + expect(InstanceIdentityKeys).toContain("instance.title"); + }); }); - it("falls back to the siteUrl when nothing else is available", () => { - expect(instanceIdentity({ values: { storageNamespace: NS_P1, siteUrl: "https://x/" } }).name).toBe("https://x/"); - }); - it("tolerates a missing config", () => { - expect(instanceIdentity(undefined)).toEqual({ namespace: "", siteUrl: "", name: "" }); - }); -}); - -describe("common/instances readDirectory", () => { - it("returns an empty map when nothing is stored", () => { - expect(readDirectory(new FakeStorage())).toEqual({}); - }); - it("returns an empty map for malformed JSON", () => { - const store = new FakeStorage(); - store.setItem(DirectoryKey, "{not json"); - expect(readDirectory(store)).toEqual({}); - }); -}); - -describe("common/instances recordInstance", () => { - it("upserts an instance under its namespace", () => { - const store = new FakeStorage(); - recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store); - expect(readDirectory(store)[NS_P1]).toEqual({ siteUrl: "https://x/i/pro-1", name: "Pro One" }); - }); - it("labels with the siteUrl when no name is supplied", () => { - const store = new FakeStorage(); - recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1" }, store); - expect(readDirectory(store)[NS_P1].name).toBe("https://x/i/pro-1"); - }); - it("is a no-op without a namespace or siteUrl", () => { - const store = new FakeStorage(); - recordInstance({ namespace: "", siteUrl: "https://x/" }, store); - recordInstance({ namespace: NS_P1, siteUrl: "" }, store); - expect(readDirectory(store)).toEqual({}); - }); - it("does not rewrite an unchanged entry", () => { - const store = new FakeStorage(); - recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store); - const before = store.getItem(DirectoryKey); - store.setItem = () => { - throw new Error("must not write"); - }; - expect(() => recordInstance({ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }, store)).not.toThrow(); - expect(store.getItem(DirectoryKey)).toBe(before); - }); -}); - -describe("common/instances recordInstanceFromConfig", () => { - it("records the identity derived from a config", () => { - const store = new FakeStorage(); - recordInstanceFromConfig({ values: { storageNamespace: NS_P2, siteUrl: "https://x/i/pro-2", siteCaption: "Pro Two" } }, store); - expect(readDirectory(store)[NS_P2]).toEqual({ siteUrl: "https://x/i/pro-2", name: "pro-2" }); - }); -}); - -describe("common/instances signedInInstances", () => { - it("returns other instances that currently hold a live session, excluding the current one", () => { - const store = new FakeStorage(); - seedDirectory(store); - store.setItem(`pp:${NS_PORTAL}:session.token`, "tok-portal"); - store.setItem(`pp:${NS_P1}:session.token`, "tok-p1"); - // pro-2 is in the directory but has no live session token. - const config = { values: { storageNamespace: NS_PORTAL } }; - expect(signedInInstances(config, store)).toEqual([{ namespace: NS_P1, siteUrl: "https://x/i/pro-1", name: "Pro One" }]); - }); - it("returns an empty list when no sibling has a live session", () => { - const store = new FakeStorage(); - seedDirectory(store); - store.setItem(`pp:${NS_PORTAL}:session.token`, "tok-portal"); - expect(signedInInstances({ values: { storageNamespace: NS_PORTAL } }, store)).toEqual([]); - }); - it("sorts targets by name", () => { - const store = new FakeStorage(); - seedDirectory(store); - store.setItem(`pp:${NS_P1}:session.token`, "tok-p1"); - store.setItem(`pp:${NS_P2}:session.token`, "tok-p2"); - const names = signedInInstances({ values: { storageNamespace: NS_PORTAL } }, store).map((t) => t.name); - expect(names).toEqual(["Pro One", "Pro Two"]); + + describe("listReachableInstances", () => { + it("returns peer instances excluding the current namespace", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/", title: "Pro One" }); + seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ namespace: "ns-pro-2", url: "https://pro-2.example.com/", title: "Pro Two" }); + }); + it("falls back to the url when no title is recorded", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" }); + seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result[0].title).toBe("https://pro-2.example.com/"); + }); + it("ignores namespaces that have a session but no recorded identity", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" }); + seedInstance(store, "ns-pro-2", { token: "tok2" }); // session only, no identity + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result).toHaveLength(0); + }); + it("ignores namespaces that have an identity but no live session", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" }); + seedInstance(store, "ns-pro-2", { token: "", url: "https://pro-2.example.com/" }); // identity only + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result).toHaveLength(0); + }); + it("returns an empty array for a standalone deployment", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result).toEqual([]); + }); + it("de-duplicates a namespace seen more than once", () => { + const store = new StorageShim(); + seedInstance(store, "ns-pro-1", { url: "https://pro-1.example.com/" }); + seedInstance(store, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" }); + store.setItem(buildNamespace("ns-pro-2") + "session.token", "tok-dup"); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", storage: store }); + expect(result).toHaveLength(1); + }); + it("discovers ephemeral peers from sessionStorage as well as localStorage", () => { + const local = new StorageShim(); + const session = new StorageShim(); + seedInstance(local, "ns-pro-1", { url: "https://pro-1.example.com/", title: "Pro One" }); + seedInstance(session, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", stores: [local, session] }); + expect(result.map((i) => i.namespace)).toEqual(["ns-pro-2"]); + }); + it("de-duplicates a namespace present in both storage backends", () => { + const local = new StorageShim(); + const session = new StorageShim(); + seedInstance(local, "ns-pro-1", { url: "https://pro-1.example.com/" }); + seedInstance(local, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" }); + seedInstance(session, "ns-pro-2", { url: "https://pro-2.example.com/", title: "Pro Two" }); + const result = listReachableInstances({ currentNamespace: "ns-pro-1", stores: [local, session] }); + expect(result).toHaveLength(1); + }); }); }); diff --git a/frontend/tests/vitest/component/user-menu.test.js b/frontend/tests/vitest/component/user-menu.test.js new file mode 100644 index 000000000..10b741ec7 --- /dev/null +++ b/frontend/tests/vitest/component/user-menu.test.js @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// PUserMenu derives its Switch Instance entries from listReachableInstances; mock +// it so the menu logic can be exercised without seeding browser storage. +vi.mock("common/instances", () => ({ + listReachableInstances: vi.fn(), +})); + +import { listReachableInstances } from "common/instances"; +import UserMenu from "component/navigation/user-menu.vue"; + +describe("component/navigation/user-menu", () => { + beforeEach(() => { + listReachableInstances.mockReset(); + }); + + describe("refresh", () => { + it("loads reachable peers using the current namespace", () => { + const peers = [{ namespace: "ns-pro-2", url: "https://pro-2.example.com/", title: "Pro Two" }]; + listReachableInstances.mockReturnValue(peers); + const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [] }; + UserMenu.methods.refresh.call(ctx); + expect(listReachableInstances).toHaveBeenCalledWith({ currentNamespace: "ns-pro-1" }); + expect(ctx.instances).toEqual(peers); + }); + it("yields an empty list when no peers are reachable, hiding the switcher", () => { + listReachableInstances.mockReturnValue([]); + const ctx = { $config: { values: { storageNamespace: "ns-pro-1" } }, instances: [{ namespace: "stale" }] }; + UserMenu.methods.refresh.call(ctx); + expect(ctx.instances).toEqual([]); + }); + }); + + describe("onToggle", () => { + it("refreshes the peer list when the menu opens", () => { + const refresh = vi.fn(); + UserMenu.methods.onToggle.call({ refresh }, true); + expect(refresh).toHaveBeenCalledTimes(1); + }); + it("does not refresh when the menu closes", () => { + const refresh = vi.fn(); + UserMenu.methods.onToggle.call({ refresh }, false); + expect(refresh).not.toHaveBeenCalled(); + }); + }); + + describe("onSwitch", () => { + it("navigates to the selected instance url", () => { + const navigate = vi.fn(); + UserMenu.methods.onSwitch.call({ navigate }, { url: "https://pro-2.example.com/" }); + expect(navigate).toHaveBeenCalledWith("https://pro-2.example.com/"); + }); + it("does nothing for an entry without a url", () => { + const navigate = vi.fn(); + UserMenu.methods.onSwitch.call({ navigate }, {}); + expect(navigate).not.toHaveBeenCalled(); + }); + }); +});