Files
siftlode/frontend/src/lib/logHooks.dom.test.ts
T
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +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' search boxes' keys, as main.tsx hands them over from the module definitions.
const TYPED = ["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" },
]);
});
});