fix(nav): the address follows the view through overlays, reloads and Back (review round 1)

- 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).
This commit is contained in:
2026-10-27 13:52:42 +01:00
parent 7b4e007680
commit 0d5c6273f1
50 changed files with 928 additions and 157 deletions
+96 -5
View File
@@ -10,8 +10,10 @@ import {
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
// storage it wrote) can stand in for what the address says. One round trip per page, plus the
// overlays the shell opens over any page (the channel page, the player) and the live search.
// storage it wrote) can stand in for what the address says. One round trip per page that names its
// view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the
// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it
// shows is the instance's own library, which a test stack may not have.
const STORAGE_STATE = "e2e/.auth/state.json";
@@ -26,6 +28,13 @@ async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => P
}
const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
const column = (p: Page, name: string) =>
p.getByRole("columnheader", { name: new RegExp(`^${name}`) });
async function gotoAdminPage(page: Page, id: string): Promise<void> {
await page.getByTestId("nav-admin").click();
await page.getByTestId(`nav-admin-${id}`).click();
}
test.describe("a bookmark reopens the view it was taken of", () => {
test("the feed's filters", async ({ page, browser }) => {
@@ -60,14 +69,58 @@ test.describe("a bookmark reopens the view it was taken of", () => {
test("an admin page's tab", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await page.getByTestId("nav-admin").click();
await page.getByTestId("nav-admin-users").click();
await gotoAdminPage(page, "users");
await tab(page, "Access requests").click();
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
await fresh.context().close();
});
test("a Configuration group", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "config");
const second = page.getByRole("tab").nth(1);
const name = (await second.textContent()) ?? "";
await second.click();
await expect(page).toHaveURL(/[?&]cfgtab=/);
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await fresh.context().close();
});
test("the Activity tab and its time window", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoAdminPage(page, "activity");
await tab(page, "Server log").click();
await page.getByRole("button", { name: "30 days", exact: true }).click();
await expect(page).toHaveURL(/[?&]aw=30d/);
const fresh = await bookmarked(browser, page);
await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true");
await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await fresh.context().close();
});
test("a playlist and its sort", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "playlists");
await expect(column(page, "Title")).toBeVisible();
await column(page, "Title").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]plsort=/);
const sorted = await column(page, "Title").getAttribute("aria-sort");
const fresh = await bookmarked(browser, page);
await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? "");
expect(new URL(fresh.url()).searchParams.get("pl")).toBe(
new URL(page.url()).searchParams.get("pl"),
);
await fresh.context().close();
});
test("the Channels manager's tab and name search", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await gotoPage(page, "channels");
@@ -90,6 +143,38 @@ test.describe("a bookmark reopens the view it was taken of", () => {
await fresh.context().close();
});
// A file of the test's own, uploaded for it and deleted after: the account's library is left as
// it was found.
test("the Files sort, and the file open in the viewer", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
const NAME = "bookmark-note";
// One a run cut short left behind goes first.
const left = (await (await page.request.get("/api/files")).json()) as Record<string, unknown>[];
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
await page.request.delete(`/api/files/${String(f.id)}`);
const up = await page.request.post("/api/files/upload", {
headers: { "X-Upload-Filename": `${NAME}.txt` },
data: Buffer.from("a note the bookmark reopens\n"),
});
expect(up.ok(), await up.text()).toBeTruthy();
const { id } = (await up.json()) as { id: number };
try {
await gotoPage(page, "files");
await column(page, "Name").getByRole("button").first().click();
await expect(page).toHaveURL(/[?&]fsort=name/);
const sorted = await column(page, "Name").getAttribute("aria-sort");
await page.getByText(NAME, { exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`));
const fresh = await bookmarked(browser, page);
await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible();
await fresh.keyboard.press("Escape");
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? "");
await fresh.context().close();
} finally {
await page.request.delete(`/api/files/${id}`);
}
});
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed");
await expect(page.getByTestId("video-card").first()).toBeVisible();
@@ -114,9 +199,15 @@ test.describe("a bookmark reopens the view it was taken of", () => {
});
// The search itself never reaches YouTube here: the answer is the server's shape, served by the
// test, and the request it answers is what the address named.
// test, and the request it answers is what the address named. The instance's search settings are
// pinned too (the free source, switched on): a stack left on the API source reopens the search
// waiting for its press, by design, and one with search off offers none.
test("a live YouTube search, its term and its count", async ({ page, browser }) => {
const answer = async (p: Page, seen: string[]) => {
await p.route("**/api/me", async (route) => {
const me = (await (await route.fetch()).json()) as Record<string, unknown>;
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
});
await p.route("**/api/search/youtube?**", (route) => {
seen.push(route.request().url());
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });