refactor(frontend): every stored key declares its kind, and preferences follow the account
storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KIND_OF, LS } from "./storage";
|
||||
|
||||
// Every key the app keeps in the browser is declared in storage.ts with its kind (a key without one
|
||||
// does not compile), and the kind decides where it belongs. This holds the two ways that rule drifts:
|
||||
// a key spelled out somewhere else, so the registry never learns of it, and an account preference
|
||||
// read or written straight from localStorage, so it never reaches the server and stays on one device
|
||||
// while every other device shows something else.
|
||||
|
||||
const SRC = new URL("../", import.meta.url);
|
||||
const SKIP_DIRS = new Set(["node_modules", "design-lab"]);
|
||||
const TEST = /\.test\.tsx?$/;
|
||||
|
||||
function files(dir: string): string[] {
|
||||
return readdirSync(new URL(dir, SRC), { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? SKIP_DIRS.has(e.name)
|
||||
? []
|
||||
: files(`${dir}${e.name}/`)
|
||||
: /\.tsx?$/.test(e.name) && !TEST.test(e.name)
|
||||
? [`${dir}${e.name}`]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
|
||||
|
||||
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
|
||||
// kept by the store that applies them: it paints the first frame, and for the language the
|
||||
// signed-out pages too, before the account is known.
|
||||
const SYNCED_BY_STORE: Record<string, string> = {
|
||||
theme: "lib/theme.ts",
|
||||
hints: "lib/hints.ts",
|
||||
notifSettings: "lib/notifications.ts",
|
||||
lang: "i18n/index.ts",
|
||||
};
|
||||
|
||||
describe("storage kinds", () => {
|
||||
it("spells out no key outside the registry", () => {
|
||||
// The debug recorder's BroadcastChannel is a channel name, not a stored key.
|
||||
const allowed = new Set(['"siftlode.debugSession"']);
|
||||
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
|
||||
[...text.matchAll(/["'`]siftlode\.[\w.]+["'`]/g)]
|
||||
.map((m) => m[0])
|
||||
.filter((lit) => !allowed.has(lit))
|
||||
.map((lit) => `${f}: ${lit}`),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an account preference on the server: its local copy only through usePref", () => {
|
||||
const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
|
||||
expect(prefs.length).toBeGreaterThan(10);
|
||||
const hits = prefs.flatMap(([name]) =>
|
||||
SOURCES.flatMap(({ f, text }) => {
|
||||
if (f === "lib/storage.ts" || SYNCED_BY_STORE[name] === f) return [];
|
||||
const named = text.match(new RegExp(`\\bLS\\.${name}\\b`, "g"))?.length ?? 0;
|
||||
const asLocalCopy =
|
||||
text.match(new RegExp(`\\blocalKey:\\s*LS\\.${name}\\b`, "g"))?.length ?? 0;
|
||||
return named > asLocalCopy ? [`${f}: LS.${name}`] : [];
|
||||
}),
|
||||
);
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists only preferences as kept by their store", () => {
|
||||
for (const name of Object.keys(SYNCED_BY_STORE))
|
||||
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");
|
||||
});
|
||||
|
||||
// The e2e setup seeds a browser's state by hand: a key it writes that the app no longer reads would
|
||||
// keep a spec green while setting up nothing.
|
||||
it("seeds the e2e browsers with registered keys only", () => {
|
||||
const setup = readFileSync(new URL("../e2e/global.setup.ts", SRC), "utf-8");
|
||||
const keys = [...setup.matchAll(/["'`](siftlode\.[\w.]+)["'`]/g)].map((m) => m[1]!);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
expect(keys.filter((k) => k !== "siftlode.owner" && !KIND_OF.has(k))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user