diff --git a/apps/desktop/src/renderer/src/App.test.ts b/apps/desktop/src/renderer/src/App.test.ts new file mode 100644 index 00000000..e8d56538 --- /dev/null +++ b/apps/desktop/src/renderer/src/App.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { formatIframeError } from './components/PreviewPane'; + +describe('formatIframeError', () => { + it('omits location when source or lineno is missing', () => { + expect(formatIframeError('error', 'something broke')).toBe('error: something broke'); + expect(formatIframeError('error', 'something broke', 'app.js')).toBe('error: something broke'); + expect(formatIframeError('error', 'something broke', undefined, 10)).toBe( + 'error: something broke', + ); + }); + + it('appends source and lineno when both are present', () => { + expect(formatIframeError('unhandledrejection', 'promise failed', 'app.js', 42)).toBe( + 'unhandledrejection: promise failed (app.js:42)', + ); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/PreviewPane.tsx b/apps/desktop/src/renderer/src/components/PreviewPane.tsx index ad971602..f0168ef3 100644 --- a/apps/desktop/src/renderer/src/components/PreviewPane.tsx +++ b/apps/desktop/src/renderer/src/components/PreviewPane.tsx @@ -13,6 +13,16 @@ export interface PreviewPaneProps { onPickStarter: (prompt: string) => void; } +export function formatIframeError( + kind: string, + message: string, + source?: string, + lineno?: number, +): string { + const location = source && lineno ? ` (${source}:${lineno})` : ''; + return `${kind}: ${message}${location}`; +} + export function isTrustedPreviewMessageSource( source: MessageEventSource | null, previewWindow: Window | null | undefined, @@ -46,11 +56,14 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) { } if (isIframeErrorMessage(event.data)) { - const location = - event.data.source && event.data.lineno - ? ` (${event.data.source}:${event.data.lineno})` - : ''; - pushIframeError(`${event.data.kind}: ${event.data.message}${location}`); + pushIframeError( + formatIframeError( + event.data.kind, + event.data.message, + event.data.source, + event.data.lineno, + ), + ); } } diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts new file mode 100644 index 00000000..b27c33a6 --- /dev/null +++ b/apps/desktop/src/renderer/src/store.test.ts @@ -0,0 +1,97 @@ +import type { OnboardingState } from '@open-codesign/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCodesignStore } from './store'; + +const READY_CONFIG: OnboardingState = { + hasKey: true, + provider: 'anthropic', + modelPrimary: 'claude-sonnet-4-6', + modelFast: 'claude-haiku-3', + baseUrl: null, + designSystem: null, +}; + +const initialState = useCodesignStore.getState(); + +function resetStore() { + useCodesignStore.setState({ + ...initialState, + messages: [], + previewHtml: null, + isGenerating: false, + errorMessage: null, + lastError: null, + config: READY_CONFIG, + configLoaded: true, + toastMessage: null, + iframeErrors: [], + toasts: [], + }); +} + +beforeEach(() => { + resetStore(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useCodesignStore iframe error handling', () => { + it('clears stale iframe errors when starting a new generation', async () => { + let resolveGenerate: ((value: unknown) => void) | undefined; + const generate = vi.fn( + () => + new Promise((resolve) => { + resolveGenerate = resolve; + }), + ); + + vi.stubGlobal('window', { + codesign: { + generate, + }, + }); + + useCodesignStore.setState({ iframeErrors: ['old iframe error'] }); + + const sendPromise = useCodesignStore.getState().sendPrompt({ prompt: 'make a landing page' }); + + expect(useCodesignStore.getState().iframeErrors).toEqual([]); + expect(useCodesignStore.getState().isGenerating).toBe(true); + + resolveGenerate?.({ + artifacts: [{ content: '' }], + message: 'Done.', + }); + await sendPromise; + + expect(generate).toHaveBeenCalledOnce(); + }); + + it('deduplicates consecutive identical iframe errors', () => { + const { pushIframeError } = useCodesignStore.getState(); + + pushIframeError('first'); + pushIframeError('first'); // duplicate — should be skipped + pushIframeError('second'); + pushIframeError('second'); // duplicate — should be skipped + pushIframeError('third'); + + expect(useCodesignStore.getState().iframeErrors).toEqual(['first', 'second', 'third']); + }); + + it('caps iframeErrors at 50 entries and drops the oldest when exceeded', () => { + const { pushIframeError } = useCodesignStore.getState(); + + for (let i = 0; i < 55; i++) { + pushIframeError(`error-${i}`); + } + + const errors = useCodesignStore.getState().iframeErrors; + expect(errors).toHaveLength(50); + // oldest (0-4) should have been shifted out; newest (5-54) remain + expect(errors[0]).toBe('error-5'); + expect(errors[49]).toBe('error-54'); + }); +}); diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 0856fbfe..d8e528d9 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -172,9 +172,12 @@ export const useCodesignStore = create((set, get) => ({ }, pushIframeError(message) { - set((s) => ({ - iframeErrors: [...s.iframeErrors.slice(-9), message], - })); + set((s) => { + const last = s.iframeErrors[s.iframeErrors.length - 1]; + if (last === message) return {}; + const next = [...s.iframeErrors, message]; + return { iframeErrors: next.length > 50 ? next.slice(1) : next }; + }); }, async loadConfig() {