import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { ErrorAlert } from '../ui/KeyDialog/ErrorAlert/ErrorAlert';
import { getErrorContent } from '../ui/KeyDialog/ErrorAlert/getErrorContent';
import { severityFor } from '../ui/KeyDialog/ErrorAlert/severityFor';
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' },
permissions: {
credentialBlocksSubmit: undefined,
accountHolds: () => undefined,
},
viaExtension: false,
}),
}));
class OtherBundleHttpError extends Error {
constructor(
public code: string,
public status?: number
) {
super(`${status ?? ''}: ${code}`);
}
}
const noCredentialContext = {
credentialBlocksSubmit: undefined,
accountHolds: () => undefined,
viaExtension: false,
};
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',
noCredentialContext
) 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());
});
it.each([
['duplicate_suggestion', 'This suggestion already exists'],
['suggestions_disabled', 'Suggestions are disabled'],
])('explains %s instead of showing the raw code', (code, title) => {
const { container, root } = renderFor(code);
expect(container.textContent).toContain(title);
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');
});
});
describe('ErrorAlert: whose permission is missing', () => {
const renderWith = (
code: string,
params: string[] | undefined,
credential: Parameters[2]
) => {
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
act(() => {
root.render(
getErrorContent(
new HttpError(code as ErrorStatusCode, 403, params),
'http://x',
credential
) as any
);
});
return { container, root };
};
const verdict = (credentialBlocksSubmit: boolean | undefined) => ({
credentialBlocksSubmit,
accountHolds: () => credentialBlocksSubmit,
viaExtension: false,
});
const serverDoesNotSay = verdict(undefined);
const credentialIsShort = verdict(true);
const accountIsShort = verdict(false);
it('says the key is missing, not the permission, when a suggester opens a string without one', () => {
const { container, root } = renderWith(
'suggestion_needs_existing_key',
undefined,
serverDoesNotSay
);
expect(container.textContent).toContain(
'This string has no key in Tolgee yet'
);
expect(container.textContent).not.toContain('permissions');
act(() => root.unmount());
});
it('keeps the old wording when the server does not say', () => {
const { container, root } = renderWith(
'permissions_not_sufficient_to_edit',
undefined,
serverDoesNotSay
);
expect(container.textContent).toContain('Update your API key');
act(() => root.unmount());
});
it('blames the API key when the account has the permission', () => {
const { container, root } = renderWith(
'permissions_not_sufficient_to_edit',
undefined,
credentialIsShort
);
expect(container.textContent).toContain(
"The API key this page uses doesn't have this permission"
);
expect(container.textContent).not.toContain('Update your API key');
expect(container.textContent).not.toContain('Tolgee plugin');
expect(buttons(container)).toEqual(['Learn more in Docs']);
act(() => root.unmount());
});
it('blames the API key on a scope error too, with no word about a plugin', () => {
const { container, root } = renderWith(
'operation_not_permitted',
['keys.edit'],
credentialIsShort
);
expect(container.textContent).toContain('Missing scopes: keys.edit');
expect(container.textContent).toContain(
"The API key this page uses doesn't have this permission"
);
expect(container.textContent).not.toContain('Tolgee plugin');
expect(buttons(container)).toEqual(['Learn more in Docs']);
act(() => root.unmount());
});
it('points to the plugin instead when the page is signed in through it', () => {
const { container, root } = renderWith(
'operation_not_permitted',
['keys.edit'],
{ ...credentialIsShort, viaExtension: true }
);
expect(container.textContent).toContain('Missing scopes: keys.edit');
expect(container.textContent).toContain(
"The Tolgee plugin doesn't have this permission yet"
);
act(() => root.unmount());
});
it('never hints on a server error that names no scope', () => {
const { container, root } = renderWith(
'operation_not_permitted',
undefined,
credentialIsShort
);
expect(container.textContent).toBe('Operation not permitted');
act(() => root.unmount());
});
it('says nothing about signing in when the account lacks the permission too', () => {
const { container, root } = renderWith(
'permissions_not_sufficient_to_edit',
undefined,
accountIsShort
);
expect(container.textContent).toContain('Ask a project admin');
expect(container.textContent).not.toContain('API key');
expect(container.textContent).not.toContain('Sign in');
const other = renderWith(
'operation_not_permitted',
['keys.edit'],
accountIsShort
);
expect(other.container.textContent).toBe(
'Operation not permittedMissing scopes: keys.edit'
);
act(() => root.unmount());
act(() => other.root.unmount());
});
});