Ctrl K
ComunidadGuías…

src/bugreport.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
export const BUG_CATEGORIES = [
  {
    id: 'slow',
    label: 'Slow',
    hint: "A page, save, or search took too long — or never finished",
    severity: 'Medium',
  },
  {
    id: 'broken-ui',
    label: 'Looks broken',
    hint: "Layout, chips, or text overlap, clip, or render wrong",
    severity: 'Medium',
  },
  {
    id: 'wrong-data',
    label: 'Wrong data',
    hint: "A change didn't save, or the values shown are wrong",
    severity: 'High',
  },
  {
    id: 'error',
    label: 'Error',
    hint: "Something threw, or the page came up empty",
    severity: 'High',
  },
];

export const categoryById = (id) => BUG_CATEGORIES.find((c) => c.id === id) ?? null;

export const SYMPTOM_FIELD = 'Symptom';
export const PROBE_QUESTION = 'Which probe would have caught this?';
export const PROBE_UNANSWERED = '_Unanswered. Whoever fixes this names the probe that should have caught it (cut-off, flicker, accessibility, touch, a budget or a source scan), or writes none and extends one._';
export const SYMPTOM_OPTIONS = BUG_CATEGORIES.map((c) => c.label);

const RANK = { Low: 0, Medium: 1, High: 2 };

export function severityFor(cats) {
  return cats.reduce((worst, c) => (RANK[c.severity] > RANK[worst] ? c.severity : worst), 'Medium');
}

export function resolveCategories(ids = []) {
  if (!Array.isArray(ids)) throw new Error('categories must be an array');
  return ids.map((id) => {
    const c = categoryById(id);
    if (!c) throw new Error(`Unknown bug category '${id}'`);
    return c;
  });
}

export const MAX_EVENTS = 200;

const SECRET_PARAMS = /\b(token|key|secret|password|passwd|share|sig|signature|auth)=([^&\s"'`]+)/gi;
const BEARER = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi;
const PREFIXED_KEY = /\b(wv[a-z]?|sk|pk|ucmcp|ghp|gho)_[A-Za-z0-9_-]{8,}/gi;

export function redact(text) {
  return String(text ?? '')
    .replace(BEARER, '$1 ***')
    .replace(SECRET_PARAMS, '$1=***')
    .replace(PREFIXED_KEY, '***');
}

function scrubEvent(ev) {
  const out = {};
  for (const [k, v] of Object.entries(ev ?? {})) {
    out[k] = typeof v === 'string' ? redact(v) : v;
  }
  return out;
}

function quote(note) {
  return String(note)
    .replace(/\r/g, '')
    .split('\n')
    .map((line) => '> ' + line.replace(/^\s*(#{1,6}|```|~~~)/, '\\$1'))
    .join('\n');
}

const secondsBefore = (t, at) => {
  const d = (Number(at) - Number(t)) / 1000;
  return Number.isFinite(d) ? `-${d.toFixed(1)}s` : '';
};

const flat = (v) => String(v ?? '').replace(/[\s`]+/g, ' ').trim();
const code = (v) => `\`${flat(v)}\``;
const num = (v) => (Number.isFinite(Number(v)) ? String(Number(v)) : '?');

function replayStep(ev) {
  switch (ev.kind) {
    case 'nav':
      return `navigated to ${code(ev.to)}`;
    case 'click':
      return `clicked ${code(ev.target)}`;
    case 'key':
      return `pressed ${code(ev.key)}${ev.target ? ` on ${code(ev.target)}` : ''}`;
    case 'api': {
      const status = Number(ev.status);
      const verdict = status >= 400 || status === 0 ? `**${status || 'failed'}**` : num(ev.status);
      return `**${code(ev.method)}** ${code(ev.path)} → ${verdict} in ${num(ev.ms)}ms`;
    }
    case 'error':
      return `error: ${code(ev.message)}${ev.source ? ` (${code(ev.source)}:${num(ev.line)})` : ''}`;
    case 'console':
      return `console ${code(ev.level ?? 'error')}: ${code(ev.message)}`;
    default:
      return `${code(ev.kind)}${ev.target ? ` ${code(ev.target)}` : ''}`;
  }
}

const uptimeWords = (s) => {
  const n = Number(s);
  if (!Number.isFinite(n)) return '';
  if (n < 3600) return `, up ${Math.round(n / 60)}m`;
  return `, up ${Math.round(n / 3600)}h`;
};

export function renderBugReport({ categories = [], note = '', events = [], client = {}, server = {} } = {}) {
  const cats = resolveCategories(categories);
  const text = String(note ?? '').trim();
  if (!cats.length && !text) throw new Error('A report needs a symptom or a note');

  const trace = (Array.isArray(events) ? events : []).map(scrubEvent);
  const at = Number(client.at ?? trace.at(-1)?.t ?? 0);
  const where = client.route || client.url || '';
  const symptoms = cats.map((c) => c.label);

  const subject = text ? text.split('\n')[0] : `on ${where || 'the web UI'}`;
  const title = (symptoms.length ? `${symptoms.join(' + ')}: ${subject}` : subject).slice(0, 100).trim();

  const facts = [
    ['Page', client.url],
    ['Route', client.route],
    ['Instance', server.instance],
    ['Workspace', server.workspace],
    ['Build', server.version ? `v${server.version}, started ${server.startedAt ?? '?'}${uptimeWords(server.uptime)}` : null],
    ['Viewport', client.viewport ? `${client.viewport.w} × ${client.viewport.h}` : null],
    ['Theme', client.theme],
    ['Browser', client.userAgent],
  ].filter(([, v]) => v != null && v !== '');

  const counts = trace.reduce((a, e) => {
    if (e.kind === 'error' || e.kind === 'console') a.errors++;
    else if (e.kind === 'api') { a.requests++; if (e.status >= 400 || e.status === 0) a.failed++; }
    else a.actions++;
    return a;
  }, { actions: 0, errors: 0, requests: 0, failed: 0 });

  const replay = trace.length
    ? trace.map((ev, i) => `${i + 1}. \`${secondsBefore(ev.t, at)}\` ${replayStep(ev)}`).join('\n')
    : '_The reporter filed this with no recorded actions — the session was fresh, or the recorder was cleared._';

  const md = [
    '## Report',
    '',
    symptoms.length
      ? `${cats.map((c) => `**${c.label}** (${c.hint.toLowerCase()})`).join(', ')}. Filed from the web UI${client.filedAt ? ` at ${client.filedAt}` : ''}.`
      : `No symptom picked — filed on the note alone${client.filedAt ? `, at ${client.filedAt}` : ''}.`,
    '',
    text ? quote(text) : '_The reporter added no note._',
    '',
    ...(server.forwardFailure ? [`Forward to ${server.forwardFailure.url} failed: ${redact(String(server.forwardFailure.reason))}. Filed on this instance instead.`, ''] : []),
    `## ${PROBE_QUESTION}`,
    '',
    PROBE_UNANSWERED,
    '',
    '## Where',
    '',
    '| | |',
    '| --- | --- |',
    ...facts.map(([k, v]) => `| ${k} | ${code(redact(String(v))).replace(/\|/g, '\\|')} |`),
    '',
    '## Replay',
    '',
    'Re-run these against this instance, in order. Times are seconds before the',
    'reporter clicked Report, so the last steps are the ones that hurt.',
    '',
    replay,
    '',
    '## Trace',
    '',
    `${counts.actions} actions, ${counts.errors} errors, ${counts.requests} requests (${counts.failed} failed).`,
    'Field values are never captured — the recorder keeps control names only,',
    'and secrets are stripped before the report leaves the page.',
    '',
    '```json',
    JSON.stringify(trace, null, 1),
    '```',
  ].join('\n');

  return { title, severity: severityFor(cats), symptoms, markdown: md };
}

export const FORWARD_TIMEOUT_MS = 10000;

export function bugForwardTarget(env = process.env) {
  if (env.WEAVE_TRACKER_CANONICAL === '1') return null;
  const url = String(env.WEAVE_BUG_FORWARD_URL ?? '').trim().replace(/\/+$/, '');
  const token = String(env.WEAVE_BUG_FORWARD_TOKEN ?? '').trim();
  return url && token ? { url, token } : null;
}

export async function forwardBugReport(target, body, instance, { fetchImpl = fetch, timeoutMs = FORWARD_TIMEOUT_MS } = {}) {
  let res;
  try {
    res = await fetchImpl(`${target.url}/api/bug-report`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${target.token}` },
      body: JSON.stringify({ ...body, instance }),
      signal: AbortSignal.timeout(timeoutMs),
    });
  } catch (err) {
    const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError';
    return { ok: false, reason: timedOut ? `no answer within ${timeoutMs / 1000} s` : String(err?.cause?.code ?? err?.cause?.message ?? err?.message ?? err) };
  }
  let data = null;
  try { data = await res.json(); } catch {}
  if (!res.ok) return { ok: false, reason: `answered ${res.status}${data?.error ? ` (${String(data.error).slice(0, 200)})` : ''}` };
  if (!data || typeof data !== 'object') return { ok: false, reason: `answered ${res.status} with no report` };
  return { ok: true, data };
}