Files
siftlode/frontend/src/moduleUrl.contract.test.ts
T
peter 5686759bca fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)
- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
2026-10-28 01:39:26 +01:00

517 lines
21 KiB
TypeScript

// @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<Record<Page, () => 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 });
<Modal url={{ fxb: String(id) }} />
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 (`<Array<X>>`, `<(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<string, Page>();
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</g)].map((m) => 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>(sub, PLEX_SUB_URL)")).toEqual([]);
expect(unnamed('useHistorySubview<Array<string>>(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(
"<LinkedPlayer hosted={!!channelView || !!moduleDef(page).playsVideos} />",
);
expect(REGISTERED_PAGES.filter(opensIt).sort()).toEqual(["feed", "home", "playlists"]);
});
});