- 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).
195 lines
7.1 KiB
TypeScript
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" },
|
|
]);
|
|
});
|
|
});
|