From 826db73ffdd3166c56a976b98a23e5fcafc39391 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Mon, 28 Sep 2026 12:57:14 +0200 Subject: [PATCH] calendar: hand errors and sync outcomes to a user-testing log hook A drive app's errors stay inside its sandboxed frame: the host shows them there and reports nothing, so in remote user testing nobody but the tester sees them. report.ts hands each failure (message, stack, the banner's problem kind and status), each finished sync (counts, and ids and reasons of unreadable events) and anything uncaught to globalThis.__USERTEST_REPORT__ when it exists. No titles or other row content are handed over. The app still makes no network request of its own; build.test.ts keeps checking the bundle for fetch(. The hook comes from a prelude that only the user-testing catalog build (usertest/catalog.mjs) prepends, so a published build reports nothing. Co-Authored-By: Claude Opus 5.5 --- integrations/calendar/app/controller.ts | 25 +++++++++- integrations/calendar/app/main.ts | 2 + integrations/calendar/app/report.test.ts | 31 ++++++++++++ integrations/calendar/app/report.ts | 62 ++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 integrations/calendar/app/report.test.ts create mode 100644 integrations/calendar/app/report.ts diff --git a/integrations/calendar/app/controller.ts b/integrations/calendar/app/controller.ts index 966bcba7..d2077566 100644 --- a/integrations/calendar/app/controller.ts +++ b/integrations/calendar/app/controller.ts @@ -34,6 +34,7 @@ import { type Outcome, type PendingEdit, } from './sync.js'; +import { errorStack, report } from './report.js'; /** What went wrong, in the terms the banners use (DESIGN.md ยง5.12). */ export interface Problem { @@ -412,7 +413,13 @@ export function createController( } } - const fail = (error: unknown, outcomes?: Outcome[]) => + const fail = (error: unknown, outcomes?: Outcome[]) => { + const problem = classify(error); + report('error', message(error), { + problem: problem.kind, + ...(problem.status ? { status: problem.status } : {}), + stack: errorStack(error), + }); set({ kind: 'error', message: message(error), @@ -420,10 +427,11 @@ export function createController( connections.length === 0 || (error as { reconnect?: boolean }).reconnect === true || spent(error), - problem: classify(error), + problem, ...(outcomes ? { outcomes } : {}), ...(last ? { summary: last.summary, at: last.at } : {}), }); + }; const reload = async () => { if (!meta) return; @@ -590,6 +598,19 @@ export function createController( ); stale = false; last = { summary, at: new Date() }; + report(summary.unreadable.length ? 'warn' : 'info', 'Sync finished', { + total: summary.total, + added: summary.added, + updated: summary.updated, + unchanged: summary.unchanged, + skipped: summary.skipped, + conflicts: summary.conflicts.length, + review: summary.review.length, + unreadable: summary.unreadable.map(({ id, reason }) => ({ + id, + reason, + })), + }); await reload(); set({ kind: 'ready', at: last.at, summary, outcomes: [] }); } catch (error) { diff --git a/integrations/calendar/app/main.ts b/integrations/calendar/app/main.ts index fb301c72..1d12ebd7 100644 --- a/integrations/calendar/app/main.ts +++ b/integrations/calendar/app/main.ts @@ -29,6 +29,7 @@ import { detail, editor } from './drawer.js'; import { busyDays, nextEvent, type CalEvent } from './events.js'; import { firstRun, importing, noRelay, picker } from './screens.js'; import { conflicts, review, shortcuts } from './sheets.js'; +import { reportUncaught } from './report.js'; import { anySkipped, notShown, sidebar } from './sidebar.js'; import type { ViewArgs } from './store.js'; import type { Choice, Conflict, ImportSummary } from './sync.js'; @@ -96,6 +97,7 @@ const EMPTY_SUMMARY: ImportSummary = { }; export async function view({ root, store }: ViewArgs): Promise { + reportUncaught(root.ownerDocument.defaultView ?? window); const doc = root.ownerDocument; const win = doc.defaultView!; installTheme(root, `${PLUGIN_CSS}\n${CALENDAR_CSS}`, store); diff --git a/integrations/calendar/app/report.test.ts b/integrations/calendar/app/report.test.ts new file mode 100644 index 00000000..767c5105 --- /dev/null +++ b/integrations/calendar/app/report.test.ts @@ -0,0 +1,31 @@ +// @wc-ignore-file +import { describe, expect, it, vi } from 'vitest'; +import { report } from './report.js'; + +describe('report', () => { + it('does nothing without a hook, as in every published build', () => { + expect(() => report('error', 'boom')).not.toThrow(); + expect(() => report('error', 'boom', {}, 'not a function')).not.toThrow(); + }); + + it('hands one entry to the hook a test build installs', () => { + const hook = vi.fn(); + + report('warn', 'Sync finished', { total: 3 }, hook); + + expect(hook).toHaveBeenCalledWith({ + source: 'calendar', + level: 'warn', + message: 'Sync finished', + total: 3, + }); + }); + + it('never throws when the hook does', () => { + const hook = () => { + throw new Error('collector down'); + }; + + expect(() => report('error', 'boom', {}, hook)).not.toThrow(); + }); +}); diff --git a/integrations/calendar/app/report.ts b/integrations/calendar/app/report.ts new file mode 100644 index 00000000..6261fe6a --- /dev/null +++ b/integrations/calendar/app/report.ts @@ -0,0 +1,62 @@ +// @wc-ignore-file +/** + * Hands this app's errors, and a line per finished sync, to a user-testing + * log hook, when one is installed. + * + * The app runs in a sandboxed frame, and the host shows an app's errors there + * without reporting them anywhere, so without this nobody but the person at + * the screen sees them. The app itself still makes no network request + * (build.test.ts checks the bundle for `fetch(`): the test catalog's build + * (ontola/atomic-plugins `usertest/catalog.mjs`) prepends a small prelude + * that defines `globalThis.__USERTEST_REPORT__` and posts to the collector. + * A published build has no prelude, so `report` does nothing there. + * + * Handed over: the error message and stack, the banner's problem kind and + * status, and per sync the counts plus the ids and reasons of unreadable + * events. Never: event titles, descriptions or other row content. + */ + +export type Level = 'error' | 'warn' | 'info'; + +type Hook = (entry: Record) => void; + +declare global { + var __USERTEST_REPORT__: Hook | undefined; +} + +export function report( + level: Level, + message: string, + data: Record = {}, + hook: unknown = globalThis.__USERTEST_REPORT__, +): void { + if (typeof hook !== 'function') return; + + try { + (hook as Hook)({ source: 'calendar', level, message, ...data }); + } catch { + // A broken hook never affects the app. + } +} + +export const errorStack = (error: unknown) => + error instanceof Error ? error.stack : undefined; + +/** Reports what escapes every handler. */ +export function reportUncaught(target: Window): void { + target.addEventListener('error', event => + report('error', event.message, { + kind: 'uncaught', + stack: errorStack(event.error), + }), + ); + target.addEventListener('unhandledrejection', event => + report( + 'error', + event.reason instanceof Error + ? event.reason.message + : String(event.reason), + { kind: 'unhandled-rejection', stack: errorStack(event.reason) }, + ), + ); +}