- 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.
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' 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" },
|
|
]);
|
|
});
|
|
});
|