diff --git a/package.json b/package.json index b7941aca23f..816b9aa1fc4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 9d3b64fd6af..d96033ec042 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -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; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 0cdd9299317..ae9a618d601 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -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 }>; +export type RunResult = XOR<{ error: Error }, { promise: Promise }>; 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 => { 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 => { 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 => { 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({ 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], diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 8519fb86e3c..b389dd4dcdd 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -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) { diff --git a/src/editor/commands.tsx b/src/editor/commands.tsx index 88db48a8991..ed711f9c1d1 100644 --- a/src/editor/commands.tsx +++ b/src/editor/commands.tsx @@ -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 { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 48c1b629563..1a33eb7b682 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index d7e5b70cdba..0829282b6c8 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -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 { +export async function registerCustomTranslations({ + testOnlyIgnoreCustomTranslationsCache = false, +}: { + testOnlyIgnoreCustomTranslationsCache?: boolean; +} = {}): Promise { const moduleTranslations = ModuleRunner.instance.allTranslations; doRegisterTranslations(moduleTranslations); @@ -661,7 +699,7 @@ export async function registerCustomTranslations(): Promise { try { let json: Optional; - if (Date.now() >= cachedCustomTranslationsExpire) { + if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) { json = CustomTranslationOptions.lookupFn ? CustomTranslationOptions.lookupFn(lookupUrl) : ((await (await fetch(lookupUrl)).json()) as ICustomTranslations); diff --git a/src/utils/AutoDiscoveryUtils.tsx b/src/utils/AutoDiscoveryUtils.tsx index 4297950b42b..b25de6a9716 100644 --- a/src/utils/AutoDiscoveryUtils.tsx +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -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 { 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, { diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts index f0c38064226..556c12fe05d 100644 --- a/test/languageHandler-test.ts +++ b/test/languageHandler-test.ts @@ -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 = { - [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, + await setupTranslationOverridesForTests({ + [str]: { + en: enOverride, + de: deOverride, + }, }); - CustomTranslationOptions.lookupFn = fn; - await registerCustomTranslations(); + // Now test that they *are* being used 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(); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index acd73952a5a..f8aa5cd8c2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"