diff --git a/apps/studio/ui/src/app/App.vue b/apps/studio/ui/src/app/App.vue index 69cc2ba..ba619d4 100644 --- a/apps/studio/ui/src/app/App.vue +++ b/apps/studio/ui/src/app/App.vue @@ -1,9 +1,10 @@ + + + + + + + + Studio + + + + + + + + + + {{ capturedErrors.length }} error{{ capturedErrors.length === 1 ? '' : 's' }} captured + + Clear + + + + + + + {{ err.message }} + + {{ err.stack }} + + + + + + + Route + + {{ routeName }} + {{ routePath }} + + + + Entity + {{ entity }} + + + Record + {{ recordName }} + + + User + + {{ userEmail }} + {{ userRole }} + + + + Boot + {{ bootLabel }} + + + + + + + + + + + d + + + + + + diff --git a/apps/studio/ui/src/features/debug/captured-errors.test.ts b/apps/studio/ui/src/features/debug/captured-errors.test.ts new file mode 100644 index 0000000..abcc493 --- /dev/null +++ b/apps/studio/ui/src/features/debug/captured-errors.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createRenderer, defineComponent, h, onErrorCaptured } from 'vue' + +import { useCapturedErrors } from './captured-errors.ts' + +test('capture retains the five newest errors with distinct identities and clears them', () => { + const { capture, errors, clear } = useCapturedErrors() + clear() + for (let i = 0; i < 7; i++) capture(new Error(`Failure ${i}`)) + assert.deepEqual(errors.value.map((error) => error.message), [ + 'Failure 6', 'Failure 5', 'Failure 4', 'Failure 3', 'Failure 2', + ]) + assert.equal(new Set(errors.value.map((error) => error.id)).size, 5) + assert.match(errors.value[0].stack ?? '', /Failure 6/) + clear() + assert.equal(errors.value.length, 0) +}) + +test('capturing a Vue component error preserves application error reporting', () => { + const { capture, errors, clear } = useCapturedErrors() + clear() + const failure = new Error('Component failed') + const renderer = createRenderer({ + createElement: () => ({}), createText: () => ({}), createComment: () => ({}), + insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, + parentNode: () => null, nextSibling: () => null, + }) + const Child = defineComponent({ setup() { throw failure } }) + const Parent = defineComponent({ + setup() { + onErrorCaptured(capture) + return () => h(Child) + }, + }) + const reported: unknown[] = [] + const app = renderer.createApp(Parent) + app.config.errorHandler = (error) => { reported.push(error) } + app.config.warnHandler = () => {} + app.mount({}) + assert.deepEqual(reported, [failure]) + assert.equal(errors.value[0].message, failure.message) + app.unmount() + clear() +}) diff --git a/apps/studio/ui/src/features/debug/captured-errors.ts b/apps/studio/ui/src/features/debug/captured-errors.ts new file mode 100644 index 0000000..0747f58 --- /dev/null +++ b/apps/studio/ui/src/features/debug/captured-errors.ts @@ -0,0 +1,36 @@ +import { ref } from 'vue' + +export type CapturedError = { + id: number + message: string + stack: string | undefined + timestamp: string +} + +let nextErrorID = 0 +const capturedErrors = ref([]) + +export function useCapturedErrors() { + function capture(err: unknown): void { + const error = err instanceof Error ? err : new Error(String(err)) + capturedErrors.value = [ + { + id: ++nextErrorID, + message: error.message, + stack: error.stack, + timestamp: new Date().toISOString(), + }, + ...capturedErrors.value, + ].slice(0, 5) + } + + function clear(): void { + capturedErrors.value = [] + } + + return { + errors: capturedErrors, + capture, + clear, + } +}
{{ err.stack }}