// @vitest-environment jsdom // // The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the // shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, // derived from these declarations). That only holds while the declarations are true, so this pins // them: each key belongs to one module and is no shell key, the keys a module declares are exactly // what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or // literal keys, checked here) — never a key of its own making — every key literal read from the live // query is declared, and every key is sorted as typed text or not (the client log's part). import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { moduleDef, REGISTERED_PAGES, typedUrlKeys } from "./features/navigation/moduleRegistry"; import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api"; import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState"; import { channelsParamUpdates } from "./lib/channelsUrlState"; import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { filesListParams, filesOpenParams, filesSortParams } from "./lib/filesUrlState"; import { ytSearchParams } from "./lib/ytSearchUrlState"; import { budgetParams } from "./lib/homeUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; import { playlistsParams, playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { settingsTabParams } from "./lib/settingsTab"; import { configTabParams } from "./lib/configTab"; import { usersTabParams } from "./lib/adminUsersTab"; import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl"; import { MODE_URL } from "./shell/welcomeMode"; import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState"; const SRC = __dirname; const FEED: FeedFilters = { tags: [], tagModes: DEFAULT_TAG_MODES, channelIds: [], q: "", sort: "newest", scope: "my", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", }; // What each module's own encoders write, from the encoders themselves. Every module that declares a // key has a row, so a new one cannot declare keys its codec does not write, or the other way round. const WRITES: Partial string[]>> = { feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))], home: () => Object.keys(budgetParams("short")), channels: () => Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })), plex: () => [ ...Object.keys( plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }), ), ...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })), PLEX_SUB_URL.key, ], files: () => [ ...Object.keys(filesListParams(null)), ...Object.keys(filesSortParams(null)), ...Object.keys(filesOpenParams({ open: "view", id: 1 })), ...Object.keys(filesOpenParams({ open: "play", id: 1 })), ], settings: () => Object.keys(settingsTabParams(null)), config: () => Object.keys(configTabParams(null)), users: () => Object.keys(usersTabParams(null)), playlists: () => [ ...Object.keys(playlistsParams(null, "")), ...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })), ], activity: () => [ ...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)), ...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)), ], }; function sources(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const path = join(dir, name); if (statSync(path).isDirectory()) return sources(path); return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; }); } // One of each form a writer is handed its value in: a scan that matched nothing in the tree would // pass every check. const WRITER_FIXTURE = ` useUrlMirror({ fxa: x, fxf }); useBackToClose(onClose, { fxc: "1" }); useBackToClose(() => setOpen(false), { fxd: "1" }); urlWithParams({ ...fxCodec({ ...fxState, job }), fxe: "1" }); useUrlMirror(shown ? { fxg: x } : null); `; // Where a writer outside lib/ is handed its value: the address writers' argument, and an // overlay's (Modal's `url`, `useBackToClose`'s second). An object literal (its keys are scanned), a // call of a lib/ function — the codecs, and the shell's own (pageRootParams, …) — or a choice // between them; anything else (a variable, a helper of the module's own) is a key no scan reads. const WRITER_CALLS = /\b(useUrlMirror|urlWithParams|useBackToClose)\(|\burl=\{/g; function closing(text: string, open: number): number { let depth = 0; for (let i = open; i < text.length; i++) { const c = text[i]!; if ("([{".includes(c)) depth++; else if (")]}".includes(c) && --depth === 0) return i; } return text.length; } /** The top-level pieces of `text` split at `sep` (outside any brackets). */ function topLevel(text: string, sep: string): string[] { const out: string[] = []; let depth = 0; let from = 0; for (let i = 0; i < text.length; i++) { const c = text[i]!; if ("([{".includes(c)) depth++; else if (")]}".includes(c)) depth--; else if (depth === 0 && c === sep) { out.push(text.slice(from, i)); from = i + 1; } } out.push(text.slice(from)); return out.map((s) => s.trim()); } /** The top-level pieces of every writer's object literal in `text` (`a: x`, `...f(y)`, `[K]: v`, `b`): * the values `writerValueOk` passes, read the same way, a choice's branches included — every literal * it lets through is one whose keys are scanned. */ function writerObjects(text: string): string[][] { return writerValues(text).flatMap(literalObjects); } function literalObjects(value: string): string[][] { const v = value.trim(); const choice = topLevel(v, "?"); if (choice.length === 2) return topLevel(choice[1]!, ":").flatMap(literalObjects); return v.startsWith("{") ? [topLevel(v.slice(1, closing(v, 0)), ",").filter(Boolean)] : []; } /** Where the call of the name ending at `at` opens its arguments, past a type argument list of any * depth (`>`, `<(v: V) => V>`); null when the name is not called there (an import). */ function callOpen(text: string, at: number): number | null { let i = at; while (/\s/.test(text[i] ?? "")) i++; if (text[i] === "<") { let depth = 0; for (; i < text.length; i++) { if (text[i] === "<") depth++; else if (text[i] === ">" && text[i - 1] !== "=" && --depth === 0) break; } i++; } return text[i] === "(" ? i : null; } /** The key a piece names as a literal: `key: value`, or the shorthand `key`. */ const keyOf = (piece: string) => /^(\w+)\s*:/.exec(piece)?.[1] ?? /^(\w+)$/.exec(piece)?.[1]; const LIB_EXPORTS = new Set( sources(join(SRC, "lib")).flatMap((f) => [...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!), ), ); function writerValueOk(value: string): boolean { const v = value.trim(); if (v === "null" || v === "undefined") return true; const choice = topLevel(v, "?"); if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk); if (v.startsWith("{")) { // Its spreads are a lib/ function's result too; its other pieces a literal key and its value — // a computed or a shorthand one is a key no literal scan reads. return topLevel(v.slice(1, closing(v, 0)), ",") .filter(Boolean) .every((piece) => piece.startsWith("...") ? writerValueOk(piece.slice(3)) : /^\w+\s*:/.test(piece), ); } const call = /^(\w+)\(/.exec(v); return ( call != null && LIB_EXPORTS.has(call[1]!) && closing(v, call[0].length - 1) === v.length - 1 ); } function writerValues(text: string): string[] { const out: string[] = []; for (const m of text.matchAll(WRITER_CALLS)) { const open = m.index + m[0].length - 1; const args = topLevel(text.slice(open + 1, closing(text, open)), ","); if (m[1] === "useBackToClose") { if (args.length > 1) out.push(args[1]!); } else if (args[0]) out.push(args[0]); } return out; } function unreadWriterValues(): string[] { const out: string[] = []; for (const file of sources(SRC)) { const where = relative(SRC, file).split("\\").join("/"); if (where.startsWith("lib/")) continue; for (const value of writerValues(readFileSync(file, "utf8"))) { // ui/Modal hands its own `url` prop on: its callers' values are the ones checked. if (where === "components/ui/Modal.tsx" && value === "url") continue; // The shell's page switch writes what the declarations name (moduleUrlKeys, foreignUrlKeys — // every module's keys cleared) and the shell's own: its helpers are that, held above. if (where === "features/navigation/NavigationProvider.tsx") continue; if (!writerValueOk(value)) out.push(`${where}: ${value.slice(0, 60)}`); } } return out; } // Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a // `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the // two-line form names its params before it reads them). The fragment (`hash`) is not the query. function queryKeyLiterals(): { key: string; where: string }[] { const out: { key: string; where: string }[] = []; for (const file of sources(SRC)) { const text = readFileSync(file, "utf8"); const where = relative(SRC, file).split("\\").join("/"); for (const pieces of writerObjects(text)) { for (const piece of pieces) { const key = keyOf(piece); if (key) out.push({ key, where }); } } if (!text.includes("location.search")) continue; for (const m of text.matchAll(/(\b\w+|\))\.(?:get|has)\("(\w+)"\)/g)) { if (m[1] !== "hash") out.push({ key: m[2]!, where }); } } return out; } // A key a writer computes itself (`[KEY]: …`) is one no scan can read: outside lib/ (the codecs, and // the history plumbing they feed) a writer hands the address an encoder's result instead, and the // encoder is what WRITES calls. function computedWriterKeys(inLib = false): string[] { const out: string[] = []; for (const file of sources(SRC)) { const where = relative(SRC, file).split("\\").join("/"); if (where.startsWith("lib/") !== inLib) continue; const text = readFileSync(file, "utf8"); for (const pieces of writerObjects(text)) { const computed = pieces.find((piece) => piece.startsWith("[")); if (computed) out.push(`${where}: ${computed.slice(0, 60)}`); } } return out; } // The keys whose value is what the user typed (a search box, a column filter map with a text // filter in it), and every other one: chosen from what the page offers (a tab, a sort, a filter's // option, an id). const TYPED_KEYS = ["aq", "cf", "cq", "fq", "plf", "plq", "pq", "q", "yt"]; const PICKED_KEYS = [ "aact", "at", "au", "aw", "cfgtab", "channel", "csort", "ctab", "folder", "fplay", "from", "fsort", "fview", "hb", "item", "lev", "lgrp", "live", "ljob", "lreq", "lrun", "lsrc", "maxage", "maxdur", "mindur", "normal", "notag", "pf", "pl", "plgroup", "plsort", "pqf", "pscope", "pshow", "psort", "rjob", "rst", "scope", "shorts", "show", "sort", "source", "stab", "tagmode", "tagmode_language", "tagmode_other", "tagmode_topic", "tags", "to", "utab", "v", "year", "ytn", ]; // A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState // RETIRED) — no module writes them any more. const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"]; describe("module URL keys", () => { const owner = new Map(); for (const page of REGISTERED_PAGES) { for (const key of moduleDef(page).urlKeys) owner.set(key, owner.get(key) ?? page); } it("belong to one module each", () => { const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys); expect(all.filter((k, i) => all.indexOf(k) !== i)).toEqual([]); }); it("are never a shell key", () => { const shell: readonly string[] = SHELL_KEYS; expect([...owner.keys()].filter((k) => shell.includes(k))).toEqual([]); // The welcome card's sub-view key is the shell's own. expect(shell).toContain(MODE_URL.key); }); // The channel page opens over any page, so no page's switch or stamp may clear its view's keys as // its own: they are the shell's, and exactly what the page's encoder writes. it("leave the channel page's keys to the shell", () => { const shell: readonly string[] = SHELL_KEYS; expect(CHANNEL_PAGE_KEYS.filter((k) => !shell.includes(k))).toEqual([]); const written = channelPageParamUpdates({ tab: "about", sort: "oldest", types: { includeNormal: true, includeShorts: true, includeLive: true }, }); expect(Object.keys(written).sort()).toEqual([...CHANNEL_PAGE_KEYS].sort()); expect(Object.keys(channelPageParamUpdates(null)).sort()).toEqual( [...CHANNEL_PAGE_KEYS].sort(), ); }); // The player opens over any page too, and names itself in its own history entry. it("leave the player's keys to the shell", () => { const shell: readonly string[] = SHELL_KEYS; expect(PLAYER_KEYS.filter((k) => !shell.includes(k))).toEqual([]); const written = playerParamUpdates({ watch: "v1", related: "v2", panel: "related" }); expect(Object.keys(written).sort()).toEqual([...PLAYER_KEYS].sort()); }); it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => { const declared = [...moduleDef(page).urlKeys].sort(); const writes = WRITES[page]; if (!declared.length) { expect(writes).toBeUndefined(); return; } expect(writes, `${page} declares keys but has no WRITES row`).toBeDefined(); expect([...new Set(writes!())].sort()).toEqual(declared); }); it("cover every key literal the app reads or writes in the address bar", () => { const shell: readonly string[] = SHELL_KEYS; const undeclared = queryKeyLiterals().filter( ({ key }) => !owner.has(key) && !shell.includes(key) && !RETIRED_LINK_KEYS.includes(key), ); expect(undeclared).toEqual([]); }); it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { const seen = new Set(queryKeyLiterals().map(({ key }) => key)); // A writer's literal, a one-line read, a two-line read, a retired link's. for (const key of ["page", "link", "plq", "folder", "lq"]) expect(seen).toContain(key); }); it("the writer scans see each form they are meant to", () => { const keys = writerObjects(WRITER_FIXTURE).flatMap((pieces) => pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))), ); expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf", "fxg"]); expect(writerValues(WRITER_FIXTURE)).toHaveLength(6); }); it("are written outside lib/ only as a literal or a lib/ function's result", () => { expect(computedWriterKeys()).toEqual([]); expect(unreadWriterValues()).toEqual([]); // …which the check tells apart (a check that passed everything would pass here too). expect(writerValueOk("{ page: p, ...activityParamUpdates(state) }")).toBe(true); expect(writerValueOk('shown ? playlistsParams(id, q) : playlistsParams(null, "")')).toBe(true); expect(writerValueOk("upd")).toBe(false); expect(writerValueOk("myOwnParams(v)")).toBe(false); expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false); // A key no scan reads as a literal: computed, or shorthand. expect(writerValueOk("{ page: p, [K]: v }")).toBe(false); expect(writerValueOk("{ page: p, zz }")).toBe(false); expect(writerValueOk("Object.fromEntries(rows)")).toBe(false); }); // A sub-view's address key is written by the history plumbing in lib/, from its definition: each // one is in a WRITES row (Plex's) or the shell's keys (the welcome card's), by name. it("name their sub-views' keys", () => { const defined = sources(SRC).flatMap((f) => [...readFileSync(f, "utf8").matchAll(/const (\w+): SubviewUrl m[1]!), ); expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]); }); // …and every sub-view is handed one of those, by name: a definition written any other way (inline, // `satisfies`) is one the census above does not count. it("hand each sub-view a definition the census names", () => { const handed = (text: string) => [...text.matchAll(/\buseHistorySubview\b/g)].flatMap((m) => { const open = callOpen(text, m.index + m[0].length); if (open == null) return []; const args = topLevel(text.slice(open + 1, closing(text, open)), ","); return args.length > 1 ? [args[1]!] : []; }); const named = new Set(["MODE_URL", "PLEX_SUB_URL"]); const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg)); const files = sources(SRC).filter( (f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"), ); expect(files.flatMap((f) => unnamed(readFileSync(f, "utf8")))).toEqual([]); // The call sites are seen (a scan of none would pass)… expect(new Set(files.flatMap((f) => handed(readFileSync(f, "utf8"))))).toEqual(named); // …and the check tells an inline definition from a named one. expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([ '{ key: "zz", encode, decode }', ]); expect(unnamed("useHistorySubview(sub, PLEX_SUB_URL)")).toEqual([]); expect(unnamed('useHistorySubview>(sub, { key: "zz" })')).toEqual([ '{ key: "zz" }', ]); expect(unnamed('useHistorySubview<(v: V) => V>(sub, { key: "zz" })')).toEqual([ '{ key: "zz" }', ]); // Under another name it is a call no scan sees. const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text); expect(files.filter((f) => aliased(readFileSync(f, "utf8")))).toEqual([]); expect(aliased('import { useHistorySubview as useSub } from "./history";')).toBe(true); }); it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { expect(computedWriterKeys(true).some((w) => w.startsWith("lib/history.ts"))).toBe(true); }); // What a search box puts in the address is what the user typed: the client log keeps it out below // its most detailed level (lib/logHooks), from these declarations. it("declare their typed-text keys among their own", () => { for (const page of REGISTERED_PAGES) { const def = moduleDef(page); expect( (def.typedUrlKeys ?? []).filter((k) => !def.urlKeys.includes(k)), page, ).toEqual([]); } // Every key sorted one way or the other, by name: a new one fails here until it is decided. const typed = new Set(typedUrlKeys()); const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys); expect([...typed].sort()).toEqual(TYPED_KEYS); expect(all.filter((k) => !typed.has(k)).sort()).toEqual(PICKED_KEYS); // …which the client log is handed as it installs. expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain( "installClientLog(appCopyOf, typedUrlKeys());", ); }); }); // The bookmark net (e2e/bookmark.spec.ts) opens every page that names its view, through the rail or // by its address — Plex excepted, by name there: its view is the instance's own library. describe("the bookmark net", () => { const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8"); // A test tagged with the page that opens a bookmark of what it set (`bookmarked(`). const tests = spec.split(/\n\s*test\(/).slice(1); const opened = (page: Page) => tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked(")); it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))( "takes a round trip through %s", (page) => { expect(opened(page)).toBe(true); }, ); it("names the page it leaves out", () => { expect(opened("plex")).toBe(false); expect(spec).toContain("Not Plex:"); }); }); // A page that opens the player an address names itself (as its queue) says so in its definition, and // the shell's player stands back over it: one that did not would get the shell's too — two players. describe("the player an address names", () => { const opensIt = (page: Page) => sources(join(SRC, "modules", page)).some((f) => readFileSync(f, "utf8").includes("useOpenLinkedPlayer("), ); it.each([...REGISTERED_PAGES])("is opened by %s exactly when it says it plays videos", (page) => { expect(!!moduleDef(page).playsVideos).toBe(opensIt(page)); }); it("is left to the shell on every other page", () => { const app = readFileSync(join(SRC, "shell", "App.tsx"), "utf8"); expect(app).toContain( "", ); expect(REGISTERED_PAGES.filter(opensIt).sort()).toEqual(["feed", "home", "playlists"]); }); });