Files
siftlode/frontend/src/lib/logHooks.dom.test.ts
T
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +01:00

195 lines
7.1 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { clear, events, setLevel } from "./log";
import { installClientLog } from "./logHooks";
// History is wrapped ONCE per window (installClientLog is idempotent), so this file installs once
// and every test reads only what it caused.
// The app passes i18n/appCopy.ts; here only "Frissítés" (and "Refresh") is app copy, read as "Refresh".
const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
// The pages' typed keys, as main.tsx hands them over from the module definitions.
const TYPED = ["q", "fq", "yt"];
beforeAll(() => {
installClientLog(appCopy, TYPED);
installClientLog(appCopy, TYPED); // a hot reload's second call must not wrap History twice
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
clear();
});
afterEach(() => {
setLevel("INFO");
window.history.replaceState(null, "", "/");
clear();
});
const views = () => events().filter((e) => e.event === "nav.view");
describe("client log hooks", () => {
it("records a page change once, and not a replaceState that only saves a scroll position", () => {
window.history.pushState({ sfPage: "feed" }, "", "/?show=all");
window.history.replaceState({ sfPage: "feed", sfScroll: 120 }, "");
window.history.replaceState({ sfPage: "feed", sfScroll: 480 }, "");
expect(views()).toHaveLength(1);
expect(views()[0]!.attrs).toMatchObject({
via: "push",
page: "feed",
path: "/",
params: ["show"],
overlay: false,
});
});
it("keeps parameter NAMES at the base level, values at DEBUG, typed search only at TRACE", () => {
window.history.pushState({ sfPage: "feed" }, "", "/?tags=music&q=lofi");
expect(views()[0]!.attrs).not.toHaveProperty("values");
expect(JSON.stringify(views())).not.toContain("lofi");
setLevel("DEBUG");
window.history.pushState({ sfPage: "plex" }, "", "/?tags=music&q=lofi");
expect(views()[1]!.attrs!.values).toEqual({ tags: "music" });
setLevel("TRACE");
window.history.pushState({ sfPage: "feed", _yt: "lofi" }, "", "/?tags=music&q=lofi");
expect(views()[2]!.attrs).toMatchObject({
values: { tags: "music", q: "lofi" },
query: "lofi",
});
});
it("keeps a page's own search box out below TRACE too", () => {
setLevel("DEBUG");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words&yt=lofi");
expect(views()[0]!.attrs!.values).toEqual({ folder: "all" });
expect(JSON.stringify(views())).not.toContain("private");
setLevel("TRACE");
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words");
expect(views()[1]!.attrs!.values).toEqual({ folder: "all", fq: "private words" });
});
it("records an overlay opening as a view of its own, and Back as a pop", () => {
window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
// What Back does: the browser restores the previous entry's state (the unwrapped prototype
// method, as the browser itself would not go through the app's wrapper), then fires popstate.
History.prototype.replaceState.call(window.history, { sfPage: "feed" }, "");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(views().map((e) => [e.attrs!.via, e.attrs!.overlay])).toEqual([
["push", true],
["pop", false],
]);
});
it("never records a token that rides in the address", () => {
window.history.replaceState(
{ sfPage: "welcome" },
"",
"/watch/Ab3xYz9Tok/unlock?token=zzz#reset=yyy",
);
setLevel("TRACE");
window.history.pushState(
{ sfPage: "welcome2" },
"",
"/watch/Ab3xYz9Tok/unlock?token=zzz#reset=yyy",
);
const text = JSON.stringify(views());
expect(text).not.toContain("Ab3xYz9Tok");
expect(text).not.toContain("zzz");
expect(text).not.toContain("yyy");
});
it("records uncaught errors and unhandled rejections", () => {
window.dispatchEvent(
new ErrorEvent("error", {
error: new TypeError("x is undefined"),
message: "x is undefined",
filename: "http://localhost/assets/index-B1x9.js",
lineno: 3,
colno: 7,
}),
);
const rejection = new Event("unhandledrejection") as Event & { reason: unknown };
rejection.reason = new Error("nope");
window.dispatchEvent(rejection);
window.dispatchEvent(new Event("error")); // a failed <img> load: not a script fault
expect(events().map((e) => [e.event, e.level])).toEqual([
["app.uncaught", "ERROR"],
["app.unhandled_rejection", "ERROR"],
]);
expect(events()[0]!.attrs).toMatchObject({
error: "TypeError",
where: "/assets/index-B1x9.js:3:7",
});
});
});
describe("control capture (a raised diagnostics level only)", () => {
const clicks = () => events().filter((e) => e.event === "ui.click");
afterEach(() => {
document.body.innerHTML = "";
});
it("records nothing at the base level", () => {
document.body.innerHTML = `<button data-testid="feed-refresh">Refresh</button>`;
document.querySelector("button")!.click();
expect(clicks()).toEqual([]);
});
it("names a control by its test id, icon, app-copy label and state", () => {
setLevel("DEBUG");
window.history.replaceState({ sfPage: "feed" }, "");
document.body.innerHTML = `
<div data-testid="feed-toolbar">
<button aria-pressed="true"><svg class="lucide lucide-refresh-cw"></svg> Frissítés </button>
</div>`;
document.querySelector("svg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(clicks().map((e) => e.attrs)).toEqual([
{
tag: "button",
within: "feed-toolbar",
icon: "refresh-cw",
label: "Refresh",
pressed: "true",
page: "feed",
},
]);
});
it("never records what the user wrote: a title on a card, an input's value, a link's query", () => {
setLevel("TRACE");
document.body.innerHTML = `
<button data-testid="video-card">My secret holiday video</button>
<input type="text" value="typed private words" />
<input type="checkbox" checked />
<a href="/watch/Ab3xYz9Tok?token=zzz">Some video title</a>
<a href="https://example.org/profile/someone">elsewhere</a>
<div role="button" aria-label="Delete tag holiday2024">x</div>`;
for (const el of document.querySelectorAll("button, input, a, [role=button]")) {
el.addEventListener("click", (e) => e.preventDefault());
(el as HTMLElement).click();
}
const text = JSON.stringify(clicks());
for (const secret of [
"holiday",
"typed private",
"Ab3xYz9Tok",
"zzz",
"Some video",
"someone",
]) {
expect(text).not.toContain(secret);
}
expect(clicks().map((e) => e.attrs)).toMatchObject([
{ control: "video-card" },
{ tag: "input", type: "text" },
{ tag: "input", type: "checkbox", checked: false }, // the state the click is turning it to
{ tag: "a", href: "/watch/{}" },
{ tag: "a", href: "external" },
{ tag: "div", role: "button" },
]);
});
});