Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions integrations/calendar/app/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -412,18 +413,25 @@ 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),
reconnect:
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;
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions integrations/calendar/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -96,6 +97,7 @@ const EMPTY_SUMMARY: ImportSummary = {
};

export async function view({ root, store }: ViewArgs): Promise<void> {
reportUncaught(root.ownerDocument.defaultView ?? window);
const doc = root.ownerDocument;
const win = doc.defaultView!;
installTheme(root, `${PLUGIN_CSS}\n${CALENDAR_CSS}`, store);
Expand Down
31 changes: 31 additions & 0 deletions integrations/calendar/app/report.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 62 additions & 0 deletions integrations/calendar/app/report.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;

declare global {
var __USERTEST_REPORT__: Hook | undefined;
}

export function report(
level: Level,
message: string,
data: Record<string, unknown> = {},
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) },
),
);
}
Loading