import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { ErrorAlert, getErrorContent, severityFor, } from '../ui/KeyDialog/ErrorAlert'; import { HttpError, ErrorStatusCode, isHttpError, } from '../ui/client/HttpError'; import { OPEN_PLUGIN_MESSAGE } from '../constants'; jest.mock('../ui/KeyDialog/dialogContext', () => ({ useDialogContext: (select: (c: unknown) => unknown) => select({ uiProps: { apiUrl: 'http://x' } }), })); class OtherBundleHttpError extends Error { constructor( public code: string, public status?: number ) { super(`${status ?? ''}: ${code}`); } } const renderFor = (code: string) => { const container = document.createElement('div'); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render( getErrorContent(new HttpError(code as ErrorStatusCode), 'http://x') as any ); }); return { container, root }; }; // The extension's content script answers TOLGEE_PING with TOLGEE_PONG; stand in for it. const withExtension = () => { const answer = (e: MessageEvent) => { if (e.data?.type === 'TOLGEE_PING') { window.postMessage({ type: 'TOLGEE_PONG' }, window.origin); } }; window.addEventListener('message', answer); return () => window.removeEventListener('message', answer); }; const settle = (ms = 50) => act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); const buttons = (container: HTMLElement) => [...container.querySelectorAll('button, a')].map((b) => b.textContent); describe('ErrorAlert getErrorContent: OAuth recovery', () => { const jwtCodes = [ 'unauthenticated', 'invalid_jwt_token', 'expired_jwt_token', 'general_jwt_error', // Emitted by OAuth2AccessTokenResolver for the extension's opaque Bearer token — a dead or revoked OAuth // session needs the same "reconnect via the plugin" recovery as a dead webapp JWT. 'invalid_oauth_token', 'oauth_token_expired', // Answered by the extension itself when the page it proxies for has no session. 'extension_session_missing', ]; it.each(jwtCodes)( 'renders the sign-in-again recovery for %s', async (code) => { const detach = withExtension(); const { container, root } = renderFor(code); await settle(); expect(container.textContent).toContain("You're not signed in"); expect(buttons(container)).toContain('Open the Tolgee plugin'); act(() => root.unmount()); detach(); } ); it('the recovery button posts the open-plugin message', async () => { const detach = withExtension(); const { container, root } = renderFor('expired_jwt_token'); await settle(); detach(); const spy = jest .spyOn(window, 'postMessage') .mockImplementation(() => undefined); act(() => { container .querySelector('button') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(spy).toHaveBeenCalledWith( { type: OPEN_PLUGIN_MESSAGE }, window.origin ); spy.mockRestore(); act(() => root.unmount()); }); it('renders the missing-projectId guidance', () => { const { container, root } = renderFor('project_id_not_specified'); expect(container.textContent).toContain('project id'); act(() => root.unmount()); }); it('names the payload cap when the extension refuses an oversized image', () => { const { container, root } = renderFor('extension_request_too_large'); expect(container.textContent).toContain('Image is too large to upload'); expect(container.textContent).toContain('too large for the Tolgee plugin'); act(() => root.unmount()); }); }); describe('ErrorAlert: missing credentials', () => { it('is informational, not an error, whether the page or the extension holds no session, and offers both ways to sign in', async () => { expect(severityFor(new HttpError('api_key_not_specified'))).toBe('info'); expect(severityFor(new HttpError('extension_session_missing', 401))).toBe( 'info' ); expect(severityFor(new HttpError('invalid_jwt_token'))).toBe('error'); expect(severityFor(new Error('boom'))).toBe('error'); const detach = withExtension(); const { container, root } = renderFor('api_key_not_specified'); await settle(); expect(container.textContent).toContain('Sign in to make changes'); expect(container.textContent).toContain('API key'); expect(buttons(container)).toContain('Open the Tolgee plugin'); act(() => root.unmount()); detach(); }); it('says editing is switched off, as information, and offers to reopen the plugin rather than to sign in', async () => { expect(severityFor(new HttpError('extension_editing_off'))).toBe('info'); const detach = withExtension(); const { container, root } = renderFor('extension_editing_off'); await settle(); expect(container.textContent).toContain( 'In-context editing is switched off' ); expect(container.textContent).toContain( 'You switched editing off for this page in the Tolgee plugin. Turn it on to edit here.' ); expect(container.textContent).not.toContain('Sign in'); expect(buttons(container)).toContain('Open the Tolgee plugin'); act(() => root.unmount()); detach(); }); it('tags the alert with the error code it renders', () => { const container = document.createElement('div'); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render( ); }); const alert = container.querySelector('[data-cy="error-alert"]'); expect(alert?.getAttribute('data-cy-error-code')).toBe( 'extension_editing_off' ); act(() => root.unmount()); }); it('offers the store link instead when no extension answers', async () => { const { container, root } = renderFor('api_key_not_specified'); // detectExtension gives up after two 300 ms attempts. await settle(800); expect(buttons(container)).toContain('Install the Tolgee plugin'); expect(buttons(container)).not.toContain('Open the Tolgee plugin'); const links = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href') ); expect(links.some((h) => h?.includes('chrome.google.com/webstore'))).toBe( true ); act(() => root.unmount()); }); }); describe('ErrorAlert: cross-bundle HttpError', () => { it('is recognised by isHttpError although it is not an instance of the local HttpError class', () => { const foreign = new OtherBundleHttpError('extension_session_missing', 401); expect(foreign).not.toBeInstanceOf(HttpError); expect(isHttpError(foreign)).toBe(true); }); it('renders the extension_session_missing content for a cross-bundle error, not its raw message', () => { const foreign = new OtherBundleHttpError('extension_session_missing', 401); const container = document.createElement('div'); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render(); }); expect(container.textContent).toContain("You're not signed in"); expect(container.textContent).not.toContain('extension_session_missing'); act(() => root.unmount()); }); it('scores severity for a cross-bundle api_key_not_specified as informational, not an error', () => { const foreign = new OtherBundleHttpError('api_key_not_specified'); expect(severityFor(foreign as unknown as HttpError)).toBe('info'); }); });