diff --git a/craft/src/utils/report-payload.test.ts b/craft/src/utils/report-payload.test.ts new file mode 100644 index 0000000..c90acef --- /dev/null +++ b/craft/src/utils/report-payload.test.ts @@ -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(); + }); +}); diff --git a/craft/src/utils/report-payload.ts b/craft/src/utils/report-payload.ts new file mode 100644 index 0000000..86254b5 --- /dev/null +++ b/craft/src/utils/report-payload.ts @@ -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; +}