src/bugreport.js
Rama main · réplica de lectura del repositorio original.
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 };
}