feat(site-builder): add pure issue-report payload builder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:08:46 -07:00
co-authored by Claude Opus 5
parent 4cfcccd272
commit fd7f883d6a
2 changed files with 157 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import { describe, test, expect } from 'vitest';
import { buildReportPayload, MAX_PAYLOAD_BYTES } from './report-payload';
const base = {
category: 'bug' as const,
description: 'The HTML block colours do nothing',
includeCanvas: true,
siteId: 42,
siteDomain: 'example.com',
pageId: 'home',
pageSlug: 'index',
editorVersion: 'abc1234-2026-08-08',
userAgent: 'Mozilla/5.0 test',
viewport: '1920x1080',
deviceMode: 'desktop',
selectedType: 'HTML',
consoleErrors: [{ ts: 1, message: 'oops' }],
canvasState: '{"ROOT":{}}',
};
describe('buildReportPayload', () => {
test('carries every context field through', () => {
const p = buildReportPayload(base);
expect(p.category).toBe('bug');
expect(p.description).toBe('The HTML block colours do nothing');
expect(p.site_id).toBe(42);
expect(p.site_domain).toBe('example.com');
expect(p.page_slug).toBe('index');
expect(p.editor_version).toBe('abc1234-2026-08-08');
expect(p.device_mode).toBe('desktop');
expect(p.selected_type).toBe('HTML');
expect(p.console_errors).toEqual([{ ts: 1, message: 'oops' }]);
expect(p.canvas_state).toBe('{"ROOT":{}}');
expect(p.canvas_state_omitted).toBeUndefined();
});
test('includeCanvas=false drops the canvas and records why', () => {
const p = buildReportPayload({ ...base, includeCanvas: false });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBe('opt-out');
expect(p.console_errors).toHaveLength(1);
expect(p.site_domain).toBe('example.com');
});
test('an oversized canvas is dropped rather than truncated', () => {
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES + 1000);
const p = buildReportPayload({ ...base, canvasState: huge });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBe('size');
});
test('the resulting payload always fits under the cap', () => {
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES * 2);
const p = buildReportPayload({ ...base, canvasState: huge });
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
});
test('description is trimmed', () => {
expect(buildReportPayload({ ...base, description: ' spaced ' }).description).toBe('spaced');
});
test('a null canvasState is reported as opt-out-free but still null', () => {
const p = buildReportPayload({ ...base, canvasState: null });
expect(p.canvas_state).toBeNull();
expect(p.canvas_state_omitted).toBeUndefined();
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { ConsoleErrorEntry } from './console-buffer';
/**
* Assembles the JSON body for an in-builder issue report.
*
* Pure: every environment value (user agent, viewport, serialized canvas)
* is passed IN rather than read from globals, so the whole thing is testable
* without a DOM and the caller decides what it is willing to send.
*
* The canvas state is the only field that can be large. When it would push
* the body over the cap it is DROPPED WHOLE and flagged -- a truncated craft
* state is not merely useless, it is misleading (it looks like a valid tree
* that lost nodes).
*/
export const MAX_PAYLOAD_BYTES = 512 * 1024;
export type ReportCategory = 'bug' | 'confusing' | 'feature';
export interface BuildReportPayloadInput {
category: ReportCategory;
description: string;
includeCanvas: boolean;
siteId: number | null;
siteDomain: string;
pageId: string;
pageSlug: string;
editorVersion: string;
userAgent: string;
viewport: string;
deviceMode: string;
selectedType: string | null;
consoleErrors: ConsoleErrorEntry[];
canvasState: string | null;
}
export interface ReportPayload {
category: ReportCategory;
description: string;
site_id: number | null;
site_domain: string;
page_id: string;
page_slug: string;
editor_version: string;
user_agent: string;
viewport: string;
device_mode: string;
selected_type: string | null;
console_errors: ConsoleErrorEntry[];
canvas_state: string | null;
/** Present only when the canvas state was dropped. */
canvas_state_omitted?: 'size' | 'opt-out';
}
function byteLength(value: string): number {
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length;
return value.length;
}
export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload {
const payload: ReportPayload = {
category: input.category,
description: input.description.trim(),
site_id: input.siteId,
site_domain: input.siteDomain,
page_id: input.pageId,
page_slug: input.pageSlug,
editor_version: input.editorVersion,
user_agent: input.userAgent,
viewport: input.viewport,
device_mode: input.deviceMode,
selected_type: input.selectedType,
console_errors: input.consoleErrors,
canvas_state: null,
};
if (!input.includeCanvas) {
payload.canvas_state_omitted = 'opt-out';
return payload;
}
if (!input.canvasState) return payload;
payload.canvas_state = input.canvasState;
if (byteLength(JSON.stringify(payload)) > MAX_PAYLOAD_BYTES) {
payload.canvas_state = null;
payload.canvas_state_omitted = 'size';
}
return payload;
}