Files
siftlode/frontend/src/lib/issueDraft.test.ts
T
peter 8d0083d4e4 feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.

- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
  markdown in the user's language, the excluded rows, and its own fields to
  reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
  from the saved draft by app/issues/bundle.py — the one definition the Gitea
  delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
  review list draws a level the way both log viewers do
2026-09-19 22:26:49 +02:00

129 lines
4.0 KiB
TypeScript

// E16 S3: the draft a session suggests, and the markdown an issue carries. Pure, so the composer's
// preview and what it saves are provably one computation.
import { describe, expect, it } from "vitest";
import type { ServerLogRow } from "./api";
import { draftSteps, osName, renderIssue, type DraftForm, type IssueText } from "./issueDraft";
let nextId = 1;
const row = (over: Partial<ServerLogRow>): ServerLogRow => ({
id: nextId++,
ts: "2026-09-19T14:05:31.000Z",
level: "INFO",
source: "web",
logger: "web",
event: "x.y",
msg: "",
attrs: null,
exc: null,
request_id: null,
user_id: 1,
job_id: null,
debug_session_id: "0123456789abcdef",
version: null,
...over,
});
const nav = (page: string) => row({ event: "nav.view", attrs: { via: "push", page } });
const step = (label?: string) =>
row({ event: "debug.step", attrs: label ? { n: 1, label } : { n: 1 } });
const pageName = (p: string) => p.toUpperCase();
const clock = () => "14:05:31";
describe("the steps a session suggests", () => {
it("are its markers and the page changes between them, in order", () => {
const steps = draftSteps(
[nav("plex"), nav("plex"), step("opened the player"), nav("settings")],
pageName,
clock,
);
expect(steps.map((s) => [s.kind, s.text])).toEqual([
["nav", "PLEX"], // the same page twice in a row is one step
["marker", "opened the player"],
["nav", "SETTINGS"],
]);
});
it("name a marker set without a label by its time, so the issue keeps it", () => {
expect(draftSteps([step()], pageName, clock)[0]?.text).toBe("14:05:31");
});
it("hang a screenshot on the step before it, and leave server rows out", () => {
const steps = draftSteps(
[
step("spinner"),
row({ event: "debug.screenshot" }),
row({ source: "api", event: "nav.view" }),
],
pageName,
clock,
);
expect(steps).toHaveLength(1);
expect(steps[0]?.shot).toBe(true);
});
});
const TEXT: IssueText = {
steps: "Steps",
environment: "Environment",
envKeys: { version: "Version" },
screenshot: "screenshot",
attachments: "Attachments",
clientLog: (n) => `${n} browser lines`,
serverLog: (n) => `${n} server lines`,
screenshots: (n) => `${n} screenshots`,
};
const form = (over: Partial<DraftForm> = {}): DraftForm => ({
title: "t",
description: "It spins.",
steps: [
{ id: "a", kind: "nav", text: "Plex" },
{ id: "b", kind: "marker", text: " ", shot: false },
{ id: "c", kind: "marker", text: "Resume", shot: true },
],
excluded: [],
...over,
});
describe("the issue text", () => {
it("carries the description, the non-empty steps, the environment and the attachments", () => {
const md = renderIssue(
form(),
[["version", "0.93.0 | beta"]],
{ client: 3, server: 0, screenshots: 1 },
TEXT,
);
expect(md).toBe(
[
"It spins.",
"### Steps\n\n1. Plex\n2. Resume _(screenshot)_",
"### Environment\n\n| | |\n|---|---|\n| Version | 0.93.0 \\| beta |",
"### Attachments\n\n- `client.log` — 3 browser lines\n- 1 screenshots",
].join("\n\n"),
);
});
it("says when the logs were cut", () => {
const md = renderIssue(
form(),
[],
{ client: 1, server: 1, screenshots: 0 },
{
...TEXT,
truncated: "cut at 5000",
},
);
expect(md.endsWith("_cut at 5000_")).toBe(true);
});
});
describe("the system a browser runs on", () => {
it.each([
["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/152.0.0.0 Mobile", "Android 10"],
["Mozilla/5.0 (iPad; CPU OS 26_6_1 like Mac OS X) AppleWebKit/605.1.15", "iPad 26.6.1"],
["Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)", "iPhone 18.2"],
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0", "Windows NT 10.0"],
["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15", "macOS 10.15.7"],
["Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0", "Linux"],
])("%s → %s", (ua, os) => expect(osName(ua)).toBe(os));
});