Replace `newTranslatableError` with `UserFriendlyError` (#10440

* Introduce UserFriendlyError

* Replace newTranslatableError with UserFriendlyError

* Remove ITranslatableError

* Fix up some strict lints

* Document when we/why we can remove

* Update matrix-web-i18n

Includes changes to find `new UserFriendlyError`,
see https://github.com/matrix-org/matrix-web-i18n/pull/6

* Include room ID in error

* Translate fallback error

* Translate better

* Update i18n strings

* Better re-use

* Minor comment fixes
pull/28788/head^2
Eric Eastwood 2023-03-31 02:30:43 -05:00 committed by GitHub
parent 567248d5c5
commit ff1468b6d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 285 additions and 99 deletions

View File

@ -28,7 +28,7 @@
"matrix_lib_main": "./lib/index.ts",
"matrix_lib_typings": "./lib/index.d.ts",
"matrix_i18n_extra_translation_funcs": [
"newTranslatableError"
"UserFriendlyError"
],
"scripts": {
"prepublishOnly": "yarn build",
@ -203,7 +203,7 @@
"jest-mock": "^29.2.2",
"jest-raw-loader": "^1.0.1",
"matrix-mock-request": "^2.5.0",
"matrix-web-i18n": "^1.3.0",
"matrix-web-i18n": "^1.4.0",
"mocha-junit-reporter": "^2.2.0",
"node-fetch": "2",
"postcss-scss": "^4.0.4",

View File

@ -187,6 +187,11 @@ declare global {
}
interface Error {
// Standard
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
cause?: unknown;
// Non-standard
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
fileName?: string;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
@ -195,6 +200,22 @@ declare global {
columnNumber?: number;
}
// We can remove these pieces if we ever update to `target: "es2022"` in our
// TypeScript config which supports the new `cause` property, see
// https://github.com/vector-im/element-web/issues/24913
interface ErrorOptions {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
cause?: unknown;
}
interface ErrorConstructor {
new (message?: string, options?: ErrorOptions): Error;
(message?: string, options?: ErrorOptions): Error;
}
// eslint-disable-next-line no-var
var Error: ErrorConstructor;
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor {
readonly port: MessagePort;

View File

@ -30,7 +30,7 @@ import { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/
import { MatrixClientPeg } from "./MatrixClientPeg";
import dis from "./dispatcher/dispatcher";
import { _t, _td, ITranslatableError, newTranslatableError } from "./languageHandler";
import { _t, _td, UserFriendlyError } from "./languageHandler";
import Modal from "./Modal";
import MultiInviter from "./utils/MultiInviter";
import { Linkify, topicToHtml } from "./HtmlUtils";
@ -110,7 +110,7 @@ export const CommandCategories = {
other: _td("Other"),
};
export type RunResult = XOR<{ error: Error | ITranslatableError }, { promise: Promise<IContent | undefined> }>;
export type RunResult = XOR<{ error: Error }, { promise: Promise<IContent | undefined> }>;
type RunFn = (this: Command, roomId: string, args?: string) => RunResult;
@ -163,14 +163,15 @@ export class Command {
public run(roomId: string, threadId: string | null, args?: string): RunResult {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!this.runFn) {
return reject(newTranslatableError("Command error: Unable to handle slash command."));
return reject(new UserFriendlyError("Command error: Unable to handle slash command."));
}
const renderingType = threadId ? TimelineRenderingType.Thread : TimelineRenderingType.Room;
if (this.renderingTypes && !this.renderingTypes?.includes(renderingType)) {
return reject(
newTranslatableError("Command error: Unable to find rendering type (%(renderingType)s)", {
new UserFriendlyError("Command error: Unable to find rendering type (%(renderingType)s)", {
renderingType,
cause: undefined,
}),
);
}
@ -310,7 +311,7 @@ export const Commands = [
const room = cli.getRoom(roomId);
if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) {
return reject(
newTranslatableError("You do not have the required permissions to use this command."),
new UserFriendlyError("You do not have the required permissions to use this command."),
);
}
@ -345,10 +346,10 @@ export const Commands = [
(async (): Promise<void> => {
const unixTimestamp = Date.parse(args);
if (!unixTimestamp) {
throw newTranslatableError(
throw new UserFriendlyError(
"We were unable to understand the given date (%(inputDate)s). " +
"Try using the format YYYY-MM-DD.",
{ inputDate: args },
{ inputDate: args, cause: undefined },
);
}
@ -496,7 +497,10 @@ export const Commands = [
const room = cli.getRoom(roomId);
if (!room) {
return reject(
newTranslatableError("Failed to get room topic: Unable to find room (%(roomId)s", { roomId }),
new UserFriendlyError("Failed to get room topic: Unable to find room (%(roomId)s", {
roomId,
cause: undefined,
}),
);
}
@ -576,13 +580,13 @@ export const Commands = [
setToDefaultIdentityServer();
return;
}
throw newTranslatableError(
throw new UserFriendlyError(
"Use an identity server to invite by email. Manage in Settings.",
);
});
} else {
return reject(
newTranslatableError("Use an identity server to invite by email. Manage in Settings."),
new UserFriendlyError("Use an identity server to invite by email. Manage in Settings."),
);
}
}
@ -594,7 +598,15 @@ export const Commands = [
})
.then(() => {
if (inviter.getCompletionState(address) !== "invited") {
throw new Error(inviter.getErrorText(address));
const errorStringFromInviterUtility = inviter.getErrorText(address);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError(
"User (%(user)s) did not end up as invited to %(roomId)s but no error was given from the inviter utility",
{ user: address, roomId, cause: undefined },
);
}
}
}),
);
@ -743,7 +755,12 @@ export const Commands = [
return room.getCanonicalAlias() === roomAlias || room.getAltAliases().includes(roomAlias);
})?.roomId;
if (!targetRoomId) {
return reject(newTranslatableError("Unrecognised room address: %(roomAlias)s", { roomAlias }));
return reject(
new UserFriendlyError("Unrecognised room address: %(roomAlias)s", {
roomAlias,
cause: undefined,
}),
);
}
}
}
@ -898,7 +915,10 @@ export const Commands = [
const room = cli.getRoom(roomId);
if (!room) {
return reject(
newTranslatableError("Command failed: Unable to find room (%(roomId)s", { roomId }),
new UserFriendlyError("Command failed: Unable to find room (%(roomId)s", {
roomId,
cause: undefined,
}),
);
}
const member = room.getMember(userId);
@ -906,7 +926,7 @@ export const Commands = [
!member?.membership ||
getEffectiveMembership(member.membership) === EffectiveMembership.Leave
) {
return reject(newTranslatableError("Could not find user in room"));
return reject(new UserFriendlyError("Could not find user in room"));
}
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
@ -940,13 +960,16 @@ export const Commands = [
const room = cli.getRoom(roomId);
if (!room) {
return reject(
newTranslatableError("Command failed: Unable to find room (%(roomId)s", { roomId }),
new UserFriendlyError("Command failed: Unable to find room (%(roomId)s", {
roomId,
cause: undefined,
}),
);
}
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent?.getContent().users[args]) {
return reject(newTranslatableError("Could not find user in room"));
return reject(new UserFriendlyError("Could not find user in room"));
}
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
@ -975,7 +998,7 @@ export const Commands = [
!isCurrentLocalRoom(),
runFn: function (roomId, widgetUrl) {
if (!widgetUrl) {
return reject(newTranslatableError("Please supply a widget URL or embed code"));
return reject(new UserFriendlyError("Please supply a widget URL or embed code"));
}
// Try and parse out a widget URL from iframes
@ -988,14 +1011,14 @@ export const Commands = [
if (iframe.tagName.toLowerCase() === "iframe" && iframe.attrs) {
const srcAttr = iframe.attrs.find((a) => a.name === "src");
logger.log("Pulling URL out of iframe (embed code)");
if (!srcAttr) return reject(newTranslatableError("iframe has no src attribute"));
if (!srcAttr) return reject(new UserFriendlyError("iframe has no src attribute"));
widgetUrl = srcAttr.value;
}
}
}
if (!widgetUrl.startsWith("https://") && !widgetUrl.startsWith("http://")) {
return reject(newTranslatableError("Please supply a https:// or http:// widget URL"));
return reject(new UserFriendlyError("Please supply a https:// or http:// widget URL"));
}
if (WidgetUtils.canUserModifyWidgets(roomId)) {
const userId = MatrixClientPeg.get().getUserId();
@ -1017,7 +1040,7 @@ export const Commands = [
return success(WidgetUtils.setRoomWidget(roomId, widgetId, type, widgetUrl, name, data));
} else {
return reject(newTranslatableError("You cannot modify widgets in this room."));
return reject(new UserFriendlyError("You cannot modify widgets in this room."));
}
},
category: CommandCategories.admin,
@ -1041,18 +1064,22 @@ export const Commands = [
(async (): Promise<void> => {
const device = cli.getStoredDevice(userId, deviceId);
if (!device) {
throw newTranslatableError("Unknown (user, session) pair: (%(userId)s, %(deviceId)s)", {
userId,
deviceId,
});
throw new UserFriendlyError(
"Unknown (user, session) pair: (%(userId)s, %(deviceId)s)",
{
userId,
deviceId,
cause: undefined,
},
);
}
const deviceTrust = await cli.checkDeviceTrust(userId, deviceId);
if (deviceTrust.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw newTranslatableError("Session already verified!");
throw new UserFriendlyError("Session already verified!");
} else {
throw newTranslatableError(
throw new UserFriendlyError(
"WARNING: session already verified, but keys do NOT MATCH!",
);
}
@ -1060,7 +1087,7 @@ export const Commands = [
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw newTranslatableError(
throw new UserFriendlyError(
"WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session" +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
@ -1069,6 +1096,7 @@ export const Commands = [
userId,
deviceId,
fingerprint,
cause: undefined,
},
);
}
@ -1217,7 +1245,7 @@ export const Commands = [
return success(
(async (): Promise<void> => {
const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId);
if (!room) throw newTranslatableError("No virtual room for this room");
if (!room) throw new UserFriendlyError("No virtual room for this room");
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
@ -1245,7 +1273,7 @@ export const Commands = [
if (isPhoneNumber) {
const results = await LegacyCallHandler.instance.pstnLookup(userId);
if (!results || results.length === 0 || !results[0].userid) {
throw newTranslatableError("Unable to find Matrix ID for phone number");
throw new UserFriendlyError("Unable to find Matrix ID for phone number");
}
userId = results[0].userid;
}
@ -1308,7 +1336,7 @@ export const Commands = [
runFn: function (roomId, args) {
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
return reject(new UserFriendlyError("No active call in this room"));
}
call.setRemoteOnHold(true);
return success();
@ -1323,7 +1351,7 @@ export const Commands = [
runFn: function (roomId, args) {
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
return reject(new UserFriendlyError("No active call in this room"));
}
call.setRemoteOnHold(false);
return success();
@ -1337,7 +1365,7 @@ export const Commands = [
isEnabled: () => !isCurrentLocalRoom(),
runFn: function (roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return reject(newTranslatableError("Could not find room"));
if (!room) return reject(new UserFriendlyError("Could not find room"));
return success(guessAndSetDMRoom(room, true));
},
renderingTypes: [TimelineRenderingType.Room],
@ -1349,7 +1377,7 @@ export const Commands = [
isEnabled: () => !isCurrentLocalRoom(),
runFn: function (roomId, args) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) return reject(newTranslatableError("Could not find room"));
if (!room) return reject(new UserFriendlyError("Could not find room"));
return success(guessAndSetDMRoom(room, false));
},
renderingTypes: [TimelineRenderingType.Room],

View File

@ -34,7 +34,7 @@ import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
@ -448,7 +448,15 @@ export const UserOptionsSection: React.FC<{
const inviter = new MultiInviter(roomId || "");
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") {
throw new Error(inviter.getErrorText(member.userId) ?? undefined);
const errorStringFromInviterUtility = inviter.getErrorText(member.userId);
if (errorStringFromInviterUtility) {
throw new Error(errorStringFromInviterUtility);
} else {
throw new UserFriendlyError(
`User (%(user)s) did not end up as invited to %(roomId)s but no error was given from the inviter utility`,
{ user: member.userId, roomId, cause: undefined },
);
}
}
});
} catch (err) {

View File

@ -21,7 +21,7 @@ import { IContent } from "matrix-js-sdk/src/models/event";
import EditorModel from "./model";
import { Type } from "./parts";
import { Command, CommandCategories, getCommand } from "../SlashCommands";
import { ITranslatableError, _t, _td } from "../languageHandler";
import { UserFriendlyError, _t, _td } from "../languageHandler";
import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import QuestionDialog from "../components/views/dialogs/QuestionDialog";
@ -65,7 +65,7 @@ export async function runSlashCommand(
): Promise<[content: IContent | null, success: boolean]> {
const result = cmd.run(roomId, threadId, args);
let messageContent: IContent | null = null;
let error = result.error;
let error: any = result.error;
if (result.promise) {
try {
if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) {
@ -86,9 +86,8 @@ export async function runSlashCommand(
let errText;
if (typeof error === "string") {
errText = error;
} else if ((error as ITranslatableError).translatedMessage) {
// Check for translatable errors (newTranslatableError)
errText = (error as ITranslatableError).translatedMessage;
} else if (error instanceof UserFriendlyError) {
errText = error.translatedMessage;
} else if (error.message) {
errText = error.message;
} else {

View File

@ -435,6 +435,7 @@
"Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.",
"Continue": "Continue",
"Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.",
"User (%(user)s) did not end up as invited to %(roomId)s but no error was given from the inviter utility": "User (%(user)s) did not end up as invited to %(roomId)s but no error was given from the inviter utility",
"Joins room with given address": "Joins room with given address",
"Leave room": "Leave room",
"Unrecognised room address: %(roomAlias)s": "Unrecognised room address: %(roomAlias)s",

View File

@ -46,21 +46,49 @@ counterpart.setSeparator("|");
const FALLBACK_LOCALE = "en";
counterpart.setFallbackLocale(FALLBACK_LOCALE);
export interface ITranslatableError extends Error {
translatedMessage: string;
interface ErrorOptions {
// Because we're mixing the subsitution variables and `cause` into the same object
// below, we want them to always explicitly say whether there is an underlying error
// or not to avoid typos of "cause" slipping through unnoticed.
cause: unknown | undefined;
}
/**
* Helper function to create an error which has an English message
* with a translatedMessage property for use by the consumer.
* @param {string} message Message to translate.
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @returns {Error} The constructed error.
* Used to rethrow an error with a user-friendly translatable message while maintaining
* access to that original underlying error. Downstream consumers can display the
* `translatedMessage` property in the UI and inspect the underlying error with the
* `cause` property.
*
* The error message will display as English in the console and logs so Element
* developers can easily understand the error and find the source in the code. It also
* helps tools like Sentry deduplicate the error, or just generally searching in
* rageshakes to find all instances regardless of the users locale.
*
* @param message - The untranslated error message text, e.g "Something went wrong with %(foo)s".
* @param substitutionVariablesAndCause - Variable substitutions for the translation and
* original cause of the error. If there is no cause, just pass `undefined`, e.g { foo:
* 'bar', cause: err || undefined }
*/
export function newTranslatableError(message: string, variables?: IVariables): ITranslatableError {
const error = new Error(message) as ITranslatableError;
error.translatedMessage = _t(message, variables);
return error;
export class UserFriendlyError extends Error {
public readonly translatedMessage: string;
public constructor(message: string, substitutionVariablesAndCause?: IVariables & ErrorOptions) {
const errorOptions = {
cause: substitutionVariablesAndCause?.cause,
};
// Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing
// it from the list
const substitutionVariables = { ...substitutionVariablesAndCause };
delete substitutionVariables["cause"];
// Create the error with the English version of the message that we want to show
// up in the logs
const englishTranslatedMessage = _t(message, { ...substitutionVariables, locale: "en" });
super(englishTranslatedMessage, errorOptions);
// Also provide a translated version of the error in the users locale to display
this.translatedMessage = _t(message, substitutionVariables);
}
}
export function getUserLanguage(): string {
@ -373,12 +401,18 @@ export function replaceByRegexes(text: string, mapping: IVariables | Tags): stri
}
}
if (!matchFoundSomewhere) {
// The current regexp did not match anything in the input
// Missing matches is entirely possible because you might choose to show some variables only in the case
// of e.g. plurals. It's still a bit suspicious, and could be due to an error, so log it.
// However, not showing count is so common that it's not worth logging. And other commonly unused variables
// here, if there are any.
if (regexpString !== "%\\(count\\)s") {
if (
// The current regexp did not match anything in the input. Missing
// matches is entirely possible because you might choose to show some
// variables only in the case of e.g. plurals. It's still a bit
// suspicious, and could be due to an error, so log it. However, not
// showing count is so common that it's not worth logging. And other
// commonly unused variables here, if there are any.
regexpString !== "%\\(count\\)s" &&
// Ignore the `locale` option which can be used to override the locale
// in counterpart
regexpString !== "%\\(locale\\)s"
) {
logger.log(`Could not find ${regexp} in ${text}`);
}
}
@ -652,7 +686,11 @@ function doRegisterTranslations(customTranslations: ICustomTranslations): void {
* This function should be called *after* registering other translations data to
* ensure it overrides strings properly.
*/
export async function registerCustomTranslations(): Promise<void> {
export async function registerCustomTranslations({
testOnlyIgnoreCustomTranslationsCache = false,
}: {
testOnlyIgnoreCustomTranslationsCache?: boolean;
} = {}): Promise<void> {
const moduleTranslations = ModuleRunner.instance.allTranslations;
doRegisterTranslations(moduleTranslations);
@ -661,7 +699,7 @@ export async function registerCustomTranslations(): Promise<void> {
try {
let json: Optional<ICustomTranslations>;
if (Date.now() >= cachedCustomTranslationsExpire) {
if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) {
json = CustomTranslationOptions.lookupFn
? CustomTranslationOptions.lookupFn(lookupUrl)
: ((await (await fetch(lookupUrl)).json()) as ICustomTranslations);

View File

@ -19,7 +19,7 @@ import { AutoDiscovery, ClientConfig } from "matrix-js-sdk/src/autodiscovery";
import { logger } from "matrix-js-sdk/src/logger";
import { IClientWellKnown } from "matrix-js-sdk/src/matrix";
import { _t, _td, newTranslatableError } from "../languageHandler";
import { _t, UserFriendlyError } from "../languageHandler";
import { makeType } from "./TypeUtils";
import SdkConfig from "../SdkConfig";
import { ValidatedServerConfig } from "./ValidatedServerConfig";
@ -147,7 +147,7 @@ export default class AutoDiscoveryUtils {
syntaxOnly = false,
): Promise<ValidatedServerConfig> {
if (!homeserverUrl) {
throw newTranslatableError(_td("No homeserver URL provided"));
throw new UserFriendlyError("No homeserver URL provided");
}
const wellknownConfig: IClientWellKnown = {
@ -199,7 +199,7 @@ export default class AutoDiscoveryUtils {
// This shouldn't happen without major misconfiguration, so we'll log a bit of information
// in the log so we can find this bit of codee but otherwise tell teh user "it broke".
logger.error("Ended up in a state of not knowing which homeserver to connect to.");
throw newTranslatableError(_td("Unexpected error resolving homeserver configuration"));
throw new UserFriendlyError("Unexpected error resolving homeserver configuration");
}
const hsResult = discoveryResult["m.homeserver"];
@ -221,9 +221,9 @@ export default class AutoDiscoveryUtils {
logger.error("Error determining preferred identity server URL:", isResult);
if (isResult.state === AutoDiscovery.FAIL_ERROR) {
if (AutoDiscovery.ALL_ERRORS.indexOf(isResult.error as string) !== -1) {
throw newTranslatableError(isResult.error as string);
throw new UserFriendlyError(String(isResult.error));
}
throw newTranslatableError(_td("Unexpected error resolving identity server configuration"));
throw new UserFriendlyError("Unexpected error resolving identity server configuration");
} // else the error is not related to syntax - continue anyways.
// rewrite homeserver error since we don't care about problems
@ -237,9 +237,9 @@ export default class AutoDiscoveryUtils {
logger.error("Error processing homeserver config:", hsResult);
if (!syntaxOnly || !AutoDiscoveryUtils.isLivelinessError(hsResult.error)) {
if (AutoDiscovery.ALL_ERRORS.indexOf(hsResult.error as string) !== -1) {
throw newTranslatableError(hsResult.error as string);
throw new UserFriendlyError(String(hsResult.error));
}
throw newTranslatableError(_td("Unexpected error resolving homeserver configuration"));
throw new UserFriendlyError("Unexpected error resolving homeserver configuration");
} // else the error is not related to syntax - continue anyways.
}
@ -252,7 +252,7 @@ export default class AutoDiscoveryUtils {
// It should have been set by now, so check it
if (!preferredHomeserverName) {
logger.error("Failed to parse homeserver name from homeserver URL");
throw newTranslatableError(_td("Unexpected error resolving homeserver configuration"));
throw new UserFriendlyError("Unexpected error resolving homeserver configuration");
}
return makeType(ValidatedServerConfig, {

View File

@ -21,8 +21,25 @@ import {
ICustomTranslations,
registerCustomTranslations,
setLanguage,
UserFriendlyError,
} from "../src/languageHandler";
async function setupTranslationOverridesForTests(overrides: ICustomTranslations) {
const lookupUrl = "/translations.json";
const fn = (url: string): ICustomTranslations => {
expect(url).toEqual(lookupUrl);
return overrides;
};
SdkConfig.add({
custom_translations_url: lookupUrl,
});
CustomTranslationOptions.lookupFn = fn;
await registerCustomTranslations({
testOnlyIgnoreCustomTranslationsCache: true,
});
}
describe("languageHandler", () => {
afterEach(() => {
SdkConfig.unset();
@ -33,38 +50,72 @@ describe("languageHandler", () => {
const str = "This is a test string that does not exist in the app.";
const enOverride = "This is the English version of a custom string.";
const deOverride = "This is the German version of a custom string.";
const overrides: ICustomTranslations = {
// First test that overrides aren't being used
await setLanguage("en");
expect(_t(str)).toEqual(str);
await setLanguage("de");
expect(_t(str)).toEqual(str);
await setupTranslationOverridesForTests({
[str]: {
en: enOverride,
de: deOverride,
},
};
const lookupUrl = "/translations.json";
const fn = (url: string): ICustomTranslations => {
expect(url).toEqual(lookupUrl);
return overrides;
};
// First test that overrides aren't being used
await setLanguage("en");
expect(_t(str)).toEqual(str);
await setLanguage("de");
expect(_t(str)).toEqual(str);
});
// Now test that they *are* being used
SdkConfig.add({
custom_translations_url: lookupUrl,
});
CustomTranslationOptions.lookupFn = fn;
await registerCustomTranslations();
await setLanguage("en");
expect(_t(str)).toEqual(enOverride);
await setLanguage("de");
expect(_t(str)).toEqual(deOverride);
});
describe("UserFriendlyError", () => {
const testErrorMessage = "This email address is already in use (%(email)s)";
beforeEach(async () => {
// Setup some strings with variable substituations that we can use in the tests.
const deOverride = "Diese E-Mail-Adresse wird bereits verwendet (%(email)s)";
await setupTranslationOverridesForTests({
[testErrorMessage]: {
en: testErrorMessage,
de: deOverride,
},
});
});
it("includes English message and localized translated message", async () => {
await setLanguage("de");
const friendlyError = new UserFriendlyError(testErrorMessage, {
email: "test@example.com",
cause: undefined,
});
// Ensure message is in English so it's readable in the logs
expect(friendlyError.message).toStrictEqual("This email address is already in use (test@example.com)");
// Ensure the translated message is localized appropriately
expect(friendlyError.translatedMessage).toStrictEqual(
"Diese E-Mail-Adresse wird bereits verwendet (test@example.com)",
);
});
it("includes underlying cause error", async () => {
await setLanguage("de");
const underlyingError = new Error("Fake underlying error");
const friendlyError = new UserFriendlyError(testErrorMessage, {
email: "test@example.com",
cause: underlyingError,
});
expect(friendlyError.cause).toStrictEqual(underlyingError);
});
it("ok to omit the substitution variables and cause object, there just won't be any cause", async () => {
const friendlyError = new UserFriendlyError("foo error");
expect(friendlyError.cause).toBeUndefined();
});
});
});

View File

@ -110,7 +110,7 @@
dependencies:
eslint-rule-composer "^0.3.0"
"@babel/generator@^7.21.0", "@babel/generator@^7.21.1":
"@babel/generator@^7.21.0":
version "7.21.1"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd"
integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA==
@ -120,6 +120,16 @@
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/generator@^7.21.1", "@babel/generator@^7.21.3":
version "7.21.3"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.3.tgz#232359d0874b392df04045d72ce2fd9bb5045fce"
integrity sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==
dependencies:
"@babel/types" "^7.21.3"
"@jridgewell/gen-mapping" "^0.3.2"
"@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1"
"@babel/generator@^7.7.2":
version "7.20.5"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.5.tgz#cb25abee3178adf58d6814b68517c62bdbfdda95"
@ -405,11 +415,16 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.5", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.21.2":
"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.21.0":
version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3"
integrity sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ==
"@babel/parser@^7.18.5", "@babel/parser@^7.20.7", "@babel/parser@^7.21.2", "@babel/parser@^7.21.3":
version "7.21.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.3.tgz#1d285d67a19162ff9daa358d4cb41d50c06220b3"
integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
@ -1163,7 +1178,7 @@
"@babel/parser" "^7.18.10"
"@babel/types" "^7.18.10"
"@babel/traverse@^7.12.12", "@babel/traverse@^7.18.5", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.7.2":
"@babel/traverse@^7.12.12", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.7.2":
version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.2.tgz#ac7e1f27658750892e815e60ae90f382a46d8e75"
integrity sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==
@ -1179,6 +1194,22 @@
debug "^4.1.0"
globals "^11.1.0"
"@babel/traverse@^7.18.5":
version "7.21.3"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.3.tgz#4747c5e7903d224be71f90788b06798331896f67"
integrity sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==
dependencies:
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.21.3"
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.21.0"
"@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.21.3"
"@babel/types" "^7.21.3"
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.20.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.5.tgz#e206ae370b5393d94dfd1d04cd687cace53efa84"
@ -1197,7 +1228,16 @@
"@babel/helper-validator-identifier" "^7.19.1"
to-fast-properties "^2.0.0"
"@babel/types@^7.18.6", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2":
"@babel/types@^7.18.6", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.3":
version "7.21.3"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.3.tgz#4865a5357ce40f64e3400b0f3b737dc6d4f64d05"
integrity sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==
dependencies:
"@babel/helper-string-parser" "^7.19.4"
"@babel/helper-validator-identifier" "^7.19.1"
to-fast-properties "^2.0.0"
"@babel/types@^7.20.0", "@babel/types@^7.20.2":
version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.2.tgz#92246f6e00f91755893c2876ad653db70c8310d1"
integrity sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw==
@ -6528,10 +6568,10 @@ matrix-mock-request@^2.5.0:
dependencies:
expect "^28.1.0"
matrix-web-i18n@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-1.3.0.tgz#d85052635215173541f56ea1af0cbefd6e09ecb3"
integrity sha512-4QumouFjd4//piyRCtkfr24kjMPHkzNQNz09B1oEX4W3d4gdd5F+lwErqcQrys7Yl09U0S0iKCD8xPBRV178qg==
matrix-web-i18n@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-1.4.0.tgz#f383a3ebc29d3fd6eb137d38cc4c3198771cc073"
integrity sha512-+NP2h4zdft+2H/6oFQ0i2PBm00Ei6HpUHke8rklgpe/yCABBG5Q7gIQdZoxazi0DXWWtcvvIfgamPZmkg6oRwA==
dependencies:
"@babel/parser" "^7.18.5"
"@babel/traverse" "^7.18.5"