// The issue drafted from a debug session — pure functions, so what the composer shows and // what it saves are one computation, testable without a browser. // // The composer renders the markdown HERE, in the user's language, and the server carries it as it // was reviewed (backend app/issues/bundle.py): the server adds no sentence of its own. import type { ServerLogRow } from "./api"; import { FRONTEND_SHA, FRONTEND_VERSION } from "./version"; /** Where a step came from: a marker the user set, a page change, or typed in the composer. */ type StepKind = "marker" | "nav" | "manual"; export interface DraftStep { id: string; kind: StepKind; text: string; /** A screenshot was taken at this step. */ shot?: boolean; } /** What the composer edits and saves (`form` in the saved draft). */ export interface DraftForm { title: string; description: string; steps: DraftStep[]; /** Log row ids taken out of the evidence. */ excluded: number[]; } /** The steps a session suggests: every marker the user set, and the page changes between them. A * page change repeated back to back is one step; a screenshot belongs to the step before it. A * marker set without a label is named by its time (`clock`) — the one thing that finds it in the * log — rather than left empty, where the issue would drop it. */ export function draftSteps( rows: readonly ServerLogRow[], pageName: (page: string) => string, clock: (ts: string) => string, ) { const steps: DraftStep[] = []; for (const row of rows) { if (row.source !== "web") continue; const attrs = row.attrs ?? {}; if (row.event === "debug.step") { const label = typeof attrs.label === "string" ? attrs.label.trim() : ""; steps.push({ id: `r${row.id}`, kind: "marker", text: label || clock(row.ts) }); } else if (row.event === "nav.view") { const page = typeof attrs.page === "string" ? attrs.page : String(attrs.path ?? ""); const text = page ? pageName(page) : ""; const last = steps[steps.length - 1]; if (!text || (last?.kind === "nav" && last.text === text)) continue; steps.push({ id: `r${row.id}`, kind: "nav", text }); } else if (row.event === "debug.screenshot") { const last = steps[steps.length - 1]; if (last) last.shot = true; } } return steps; } /** The operating system a user agent names. Checked most specific first: Android's agent also says * "Linux" — and says it first — and an iPad's also says "Mac OS X". */ export function osName(ua: string): string { const tests: [RegExp, (m: RegExpExecArray) => string][] = [ [/Android [\d.]+/, (m) => m[0]], [/(iPhone|iPad)[^)]*? OS ([\d_]+)/, (m) => `${m[1]} ${m[2]!.replace(/_/g, ".")}`], [/Windows NT [\d.]+/, (m) => m[0]], [/Mac OS X ([\d_]+)/, (m) => `macOS ${m[1]!.replace(/_/g, ".")}`], [/CrOS|Linux/, (m) => m[0]], ]; for (const [re, name] of tests) { const m = re.exec(ua); if (m) return name(m); } return "?"; } /** The environment table: what a developer asks first. Read from this browser at composing time — * the same browser the session recorded in, or the report says which one it came from. */ export function environment(language: string): [key: string, value: string][] { const ua = navigator.userAgent; const browser = /Edg\/([\d.]+)/.exec(ua)?.[0] ?? /Firefox\/([\d.]+)/.exec(ua)?.[0] ?? /Chrome\/([\d.]+)/.exec(ua)?.[0] ?? /Version\/([\d.]+).*Safari/.exec(ua)?.[0] ?? ua; const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; return [ ["version", `${FRONTEND_VERSION} (${FRONTEND_SHA})`], ["browser", browser.replace("/", " ")], ["os", osName(ua)], [ "window", `${window.innerWidth} × ${window.innerHeight} · ${window.devicePixelRatio}x · ${Math.round((rootPx / 16) * 100)}%`, ], ["language", language], ]; } /** A table cell: a pipe or a line break would break the markdown table. */ const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); export interface IssueText { steps: string; environment: string; envKeys: Record; screenshot: string; attachments: string; clientLog: (n: number) => string; serverLog: (n: number) => string; screenshots: (n: number) => string; /** Shown when the evidence was cut at the server's cap. */ truncated?: string; } /** The issue body as markdown: the description, the steps, the environment, the attachments. */ export function renderIssue( form: DraftForm, env: readonly [string, string][], counts: { client: number; server: number; screenshots: number }, text: IssueText, ): string { const parts: string[] = []; const description = form.description.trim(); if (description) parts.push(description); const steps = form.steps.filter((s) => s.text.trim()); if (steps.length) parts.push( `### ${text.steps}\n\n` + steps .map((s, i) => `${i + 1}. ${s.text.trim()}${s.shot ? ` _(${text.screenshot})_` : ""}`) .join("\n"), ); parts.push( `### ${text.environment}\n\n| | |\n|---|---|\n` + env.map(([k, v]) => `| ${cell(text.envKeys[k] ?? k)} | ${cell(v)} |`).join("\n"), ); const attachments = [ counts.client ? `- \`client.log\` — ${text.clientLog(counts.client)}` : "", counts.server ? `- \`server.log\` — ${text.serverLog(counts.server)}` : "", counts.screenshots ? `- ${text.screenshots(counts.screenshots)}` : "", ].filter(Boolean); if (attachments.length) { const cut = text.truncated ? `\n\n_${text.truncated}_` : ""; parts.push(`### ${text.attachments}\n\n${attachments.join("\n")}${cut}`); } return parts.join("\n\n"); }