- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
200 lines
8.0 KiB
TypeScript
200 lines
8.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so
|
|
// each provider owns its own key without clobbering the others), clearParams (strip only transient
|
|
// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete
|
|
// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too.
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import {
|
|
urlWithParams,
|
|
clearParams,
|
|
filterParamUpdates,
|
|
filtersToParams,
|
|
paramsToFilters,
|
|
} from "./urlState";
|
|
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
|
|
|
|
function setLocation(search: string) {
|
|
window.history.replaceState(null, "", `/app${search}`);
|
|
}
|
|
function queryOf(url: string): URLSearchParams {
|
|
return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
}
|
|
|
|
const DEFAULTS: FeedFilters = {
|
|
tags: [],
|
|
tagModes: DEFAULT_TAG_MODES,
|
|
channelIds: [],
|
|
q: "",
|
|
sort: "newest",
|
|
scope: "my",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
};
|
|
|
|
describe("urlWithParams", () => {
|
|
beforeEach(() => setLocation(""));
|
|
|
|
it("sets a key while preserving the pathname and existing params", () => {
|
|
setLocation("?page=plex");
|
|
const url = urlWithParams({ v: "show:1" });
|
|
expect(url.startsWith("/app?")).toBe(true);
|
|
const q = queryOf(url);
|
|
expect(q.get("v")).toBe("show:1");
|
|
expect(q.get("page")).toBe("plex"); // untouched
|
|
});
|
|
|
|
it("deletes a key on a null value and leaves the others", () => {
|
|
setLocation("?page=plex&v=show:1&q=react");
|
|
const q = queryOf(urlWithParams({ v: null }));
|
|
expect(q.has("v")).toBe(false);
|
|
expect(q.get("page")).toBe("plex");
|
|
expect(q.get("q")).toBe("react");
|
|
});
|
|
|
|
it("applies a mix of sets and deletes in one call", () => {
|
|
setLocation("?page=plex&v=show:1&chan=UC1");
|
|
const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null }));
|
|
expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys
|
|
});
|
|
|
|
it("returns a bare path when the query empties out", () => {
|
|
setLocation("?page=channels");
|
|
expect(urlWithParams({ page: null })).toBe("/app");
|
|
});
|
|
|
|
it("carries the fragment over — the one-time auth tokens ride it", () => {
|
|
// Regression (prod, 2026-09-02): the providers that mirror nav params mount ABOVE the lazy <App>,
|
|
// so this URL was replaceState'd before the auth card's chunk loaded. Dropping the hash ate the
|
|
// #verify= token and the verification link opened a bare sign-in page.
|
|
window.history.replaceState(null, "", "/app?page=plex#verify=tok123");
|
|
expect(urlWithParams({ v: "show:1" })).toBe("/app?page=plex&v=show%3A1#verify=tok123");
|
|
});
|
|
|
|
it("carries the fragment over even when the query empties out", () => {
|
|
window.history.replaceState(null, "", "/app?page=channels#reset=tok456");
|
|
expect(urlWithParams({ page: null })).toBe("/app#reset=tok456");
|
|
});
|
|
});
|
|
|
|
describe("clearParams", () => {
|
|
beforeEach(() => setLocation(""));
|
|
|
|
it("removes only the named transient keys, keeping nav/filter params", () => {
|
|
setLocation("?link=ok&page=plex&v=show:1&q=react");
|
|
clearParams("link", "oauth", "admin", "access", "verify", "reset");
|
|
const q = new URLSearchParams(window.location.search);
|
|
expect(q.has("link")).toBe(false);
|
|
expect(q.get("page")).toBe("plex");
|
|
expect(q.get("v")).toBe("show:1");
|
|
expect(q.get("q")).toBe("react");
|
|
});
|
|
|
|
it("is a no-op (no history write) when none of the keys are present", () => {
|
|
setLocation("?page=plex");
|
|
const before = window.location.search;
|
|
clearParams("link", "oauth");
|
|
expect(window.location.search).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe("a page opened at a path that starts with //", () => {
|
|
// Regression: the address was glued together as `pathname + query`, and a path of `//evil.com/x`
|
|
// then reads as a link to another host. history refuses that (SecurityError), so the first nav
|
|
// mirror to write threw and the app booted into its crash screen.
|
|
const openAt = (rest: string) =>
|
|
window.history.replaceState(null, "", `${window.location.origin}//evil.com/x${rest}`);
|
|
const origin = window.location.origin;
|
|
|
|
it("writes an address on this origin and this path", () => {
|
|
openAt("?page=plex#verify=t");
|
|
window.history.replaceState(null, "", urlWithParams({ v: "show:1" }));
|
|
expect(window.location.origin).toBe(origin);
|
|
expect(window.location.pathname).toBe("//evil.com/x");
|
|
expect(window.location.search).toBe("?page=plex&v=show%3A1");
|
|
expect(window.location.hash).toBe("#verify=t");
|
|
});
|
|
|
|
it("strips a transient key without leaving this origin", () => {
|
|
openAt("?link=ok&page=plex");
|
|
clearParams("link");
|
|
expect(window.location.origin).toBe(origin);
|
|
expect(window.location.pathname).toBe("//evil.com/x");
|
|
expect(window.location.search).toBe("?page=plex");
|
|
});
|
|
});
|
|
|
|
describe("filterParamUpdates", () => {
|
|
it("maps non-default filters to values and other defaults to null (delete)", () => {
|
|
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
|
|
const upd = filterParamUpdates(f);
|
|
expect(upd.q).toBe("react");
|
|
expect(upd.tags).toBe("3,7");
|
|
expect(upd.show).toBe("watched");
|
|
expect(upd.sort).toBe("newest"); // the view and the order are always named, defaults too
|
|
expect(upd.scope).toBeNull();
|
|
});
|
|
|
|
it("clears ALL filter keys when passed null (leaving the feed)", () => {
|
|
const upd = filterParamUpdates(null);
|
|
for (const v of Object.values(upd)) expect(v).toBeNull();
|
|
// includes every recognized filter key
|
|
expect(Object.keys(upd)).toEqual(
|
|
expect.arrayContaining(["q", "tags", "show", "sort", "scope"]),
|
|
);
|
|
});
|
|
|
|
it("round-trips through the URL (filters → params → filters)", () => {
|
|
const f: FeedFilters = {
|
|
...DEFAULTS,
|
|
q: "hi",
|
|
tags: [1, 2],
|
|
scope: "all",
|
|
includeShorts: true,
|
|
};
|
|
const back = paramsToFilters(filtersToParams(f), DEFAULTS);
|
|
expect(back.q).toBe("hi");
|
|
expect(back.tags).toEqual([1, 2]);
|
|
expect(back.scope).toBe("all");
|
|
expect(back.includeShorts).toBe(true);
|
|
});
|
|
|
|
it("round-trips per-category tag modes (only 'and' is emitted)", () => {
|
|
const f: FeedFilters = {
|
|
...DEFAULTS,
|
|
tags: [1, 2],
|
|
tagModes: { language: "and", topic: "and", other: "or" },
|
|
};
|
|
const params = filtersToParams(f);
|
|
expect(params.get("tagmode_language")).toBe("and");
|
|
expect(params.get("tagmode_topic")).toBe("and");
|
|
expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted
|
|
const back = paramsToFilters(params, DEFAULTS);
|
|
expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" });
|
|
});
|
|
|
|
it("migrates a legacy global ?tagmode=and into the topic slot only", () => {
|
|
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
|
|
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
|
});
|
|
|
|
it("round-trips the year filter; emitted only when set", () => {
|
|
expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024");
|
|
expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false);
|
|
expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024);
|
|
// Junk / absent → undefined (off), never a NaN year.
|
|
expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined();
|
|
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined();
|
|
});
|
|
|
|
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
|
|
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
|
|
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
|
|
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
|
|
// Absent → the base value is left untouched (undefined here, i.e. off).
|
|
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
|
|
});
|
|
});
|