Files
siftlode/frontend/src/lib/diagnostics.dom.test.ts
T
peter 46fce4a767 feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
  retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
  its own Escape layer), Screenshot (tab capture where the browser has it),
  Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
  beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
  raises the level for the session's run and never lowers a raised one, and
  lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
  says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
  new modifier chord, and a bare letter collides with the players'
2026-09-19 22:08:10 +02:00

312 lines
13 KiB
TypeScript

// @vitest-environment jsdom
//
// E22 S5: the consented level applied to the tab, and the stream it opens. The module keeps its state
// at module level (what is applied, the queue), so every test imports a fresh copy — and the log
// from that same copy, or it would be reading a different ring.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sent: unknown[][] = [];
const unloaded: unknown[][] = [];
let answer: () => Promise<unknown> = () => Promise.resolve({ ok: true, stored: 0 });
vi.mock("./api", async () => {
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
return {
HttpError: core.HttpError,
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
api: {
sendLogEvents: (events: unknown[]) => {
sent.push(events);
return answer();
},
sendLogEventsOnUnload: (events: unknown[]) => void unloaded.push(events),
},
};
});
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
sent.length = 0;
unloaded.length = 0;
answer = () => Promise.resolve({ ok: true, stored: 0 });
});
let current: typeof import("./diagnostics") | null = null;
afterEach(() => {
// A module copy left streaming would still hear `pagehide` in the next test.
current?.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
current = null;
vi.useRealTimers();
});
async function fresh() {
const diagnostics = await import("./diagnostics");
current = diagnostics;
const logMod = await import("./log");
const apiMod = await import("./api");
return { ...diagnostics, ...logMod, HttpError: apiMod.HttpError };
}
const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
level,
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
retention_days: 7,
session_minutes: 60,
});
const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event);
describe("diagnostics level", () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh();
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
d.log.info("nav.view", "x");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
expect(d.enabled("DEBUG")).toBe(false);
expect(codes(d.events())).toEqual(["nav.view"]);
});
it("a raised level streams only what happens after it, without its own request lines", async () => {
const d = await fresh();
d.log.info("before.consent", "not sent");
d.applyDiagnostics(raised("DEBUG"), null, () => {});
expect(d.enabled("DEBUG")).toBe(true);
d.log.debug("ui.click", "click");
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
d.log.request("INFO", "GET", { path: "/api/feed", status: 200 }, "abcdef0124");
await vi.advanceTimersByTimeAsync(5_000);
expect(sent).toHaveLength(1);
expect(codes(sent[0])).toEqual(["diagnostics.level", "ui.click", "http.request"]);
expect(JSON.stringify(sent[0])).not.toContain("/api/diagnostics/events");
});
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), null, () => {});
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
expect(sent.map((b) => b.length)).toEqual([100]);
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(sent.every((b) => b.length <= 100)).toBe(true);
expect(sent.flat()).toHaveLength(251); // the 250 clicks + the line that opened the stream
});
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
const d = await fresh();
d.applyDiagnostics(raised("TRACE"), null, () => {});
d.log.trace("search.typed", "lofi");
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
d.log.info("nav.view", "after");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
expect(d.enabled("TRACE")).toBe(false);
});
it("lapses on its own timer and tells the app to refetch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG", 1), null, lapsed);
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
sent.length = 0;
d.log.info("nav.view", "after the lapse");
await vi.advanceTimersByTimeAsync(10_000);
expect(sent).toEqual([]);
});
it("an already-expired level is the base level", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG", -1), null, () => {});
expect(d.enabled("DEBUG")).toBe(false);
});
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
d.log.debug("ui.click", "kept for later");
await vi.advanceTimersByTimeAsync(5_000);
answer = () => Promise.reject(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(15_000);
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the back-off
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
});
it("after a 429 nothing is sent until the back-off is over, however full the queue", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
expect(sent).toHaveLength(1); // the full batch went at once, and was refused
answer = () => Promise.resolve({ ok: true, stored: 0 });
for (let i = 0; i < 50; i++) d.log.debug("ui.click", `more ${i}`);
await vi.advanceTimersByTimeAsync(14_000);
expect(sent).toHaveLength(1); // one POST per new event is exactly the storm to avoid
await vi.advanceTimersByTimeAsync(1_000);
expect(codes(sent[1])).toEqual(codes(sent[0]));
});
it("past the queue cap the oldest events go, a refused batch included", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 });
for (let i = 99; i < 1_100; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(60_000);
const msgs = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(msgs).toHaveLength(1_000);
expect(msgs[0]).toBe("click 100");
expect(msgs[msgs.length - 1]).toBe("click 1099");
});
it("a batch refused after the level was lowered is not sent under a later consent", async () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
refuse(new d.HttpError(429, "slow down"));
await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), null, () => {});
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(later).not.toContain("under the first consent");
expect(later).toContain("diagnostics level raised");
});
it("a batch refused after a re-raise is still not sent: it was taken under the old consent", async () => {
// The test above settles the refusal while the stream is STOPPED, so `unsubscribe` is what
// blocks the requeue there. Here it settles after the new consent is already streaming — the
// ordering only the `session` counter can catch, and the one that would leak events across a
// withdrawal if it did not.
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), null, () => {});
refuse(new d.HttpError(429, "slow down")); // only now does the old consent's batch come back
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(later).not.toContain("under the first consent");
expect(later).toContain("diagnostics level raised");
});
it("a 409 for a batch taken under the old consent does not lapse the new one", async () => {
const d = await fresh();
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
lapsed,
);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), null, lapsed);
refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(60_000);
expect(lapsed).not.toHaveBeenCalled();
expect(d.enabled("TRACE")).toBe(true);
});
it("a 409 answering a batch the user themselves cancelled is not called a lapse", async () => {
// "Alap" is the user revoking their own consent; the server answers the still-flying batch with
// the same 409 a real expiry gets. Their own log must not tell them the consent ran out.
const d = await fresh();
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
d.log.debug("ui.click", "under the consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
lapsed,
);
refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(0);
expect(lapsed).not.toHaveBeenCalled();
expect(d.events().map((e) => e.msg)).not.toContain("diagnostics level lapsed");
});
it("a refused batch that comes back to a full queue is what goes, not the events since", async () => {
// The cap test above fills the queue AFTER the refusal landed, so the trim it measures is the
// one on the subscribe path. This one has the queue already at the cap when the batch comes
// back — the only state where the trim in the 429 handler decides anything.
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), null, () => {});
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `old ${i}`);
await vi.advanceTimersByTimeAsync(0); // the 100 oldest are in flight, the queue is empty
for (let i = 0; i < 1_000; i++) d.log.debug("ui.click", `new ${i}`);
answer = () => Promise.resolve({ ok: true, stored: 0 });
refuse(new d.HttpError(429, "slow down")); // 1_000 + 100 back = 1_100, over the cap
await vi.advanceTimersByTimeAsync(60_000);
const msgs = sent
.slice(1)
.flat()
.map((e) => (e as { msg: string }).msg);
expect(msgs).toHaveLength(1_000);
expect(msgs).not.toContain("old 0");
expect([msgs[0], msgs[msgs.length - 1]]).toEqual(["new 0", "new 999"]);
});
it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "last click");
window.dispatchEvent(new Event("pagehide"));
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
expect(sent).toEqual([]);
});
});