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'
This commit is contained in:
2026-09-19 22:08:10 +02:00
parent 2d9595b0e5
commit 46fce4a767
26 changed files with 1347 additions and 89 deletions
+53 -24
View File
@@ -34,7 +34,11 @@ beforeEach(() => {
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 }, () => {});
current?.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
current = null;
vi.useRealTimers();
});
@@ -51,6 +55,7 @@ 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);
@@ -58,7 +63,11 @@ const codes = (batch: unknown[] | undefined) =>
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 }, () => {});
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([]);
@@ -69,7 +78,7 @@ describe("diagnostics level", () => {
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"), () => {});
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");
@@ -82,7 +91,7 @@ describe("diagnostics level", () => {
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
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]);
@@ -94,9 +103,13 @@ describe("diagnostics level", () => {
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"), () => {});
d.applyDiagnostics(raised("TRACE"), null, () => {});
d.log.trace("search.typed", "lofi");
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
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([]);
@@ -106,7 +119,7 @@ describe("diagnostics level", () => {
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), lapsed);
d.applyDiagnostics(raised("DEBUG", 1), null, lapsed);
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
@@ -118,14 +131,14 @@ describe("diagnostics level", () => {
it("an already-expired level is the base level", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG", -1), () => {});
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"), lapsed);
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);
@@ -138,7 +151,7 @@ describe("diagnostics level", () => {
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"), () => {});
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);
@@ -153,7 +166,7 @@ describe("diagnostics level", () => {
it("past the queue cap the oldest events go, a refused batch included", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
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);
@@ -173,14 +186,18 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
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 }, () => {});
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"), () => {});
d.applyDiagnostics(raised("TRACE"), null, () => {});
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
.slice(1)
@@ -198,12 +215,16 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
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 }, () => {});
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), () => {});
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
@@ -219,12 +240,16 @@ describe("diagnostics level", () => {
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), lapsed);
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 }, lapsed);
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"), lapsed);
d.applyDiagnostics(raised("TRACE"), null, lapsed);
refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(60_000);
expect(lapsed).not.toHaveBeenCalled();
@@ -238,10 +263,14 @@ describe("diagnostics level", () => {
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), lapsed);
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 }, lapsed);
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();
@@ -255,7 +284,7 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
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}`);
@@ -273,7 +302,7 @@ describe("diagnostics level", () => {
it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
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"]);