- 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.
517 lines
21 KiB
TypeScript
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"]);
|
|
});
|
|
});
|