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
This commit is contained in:
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_main": "./lib/index.ts",
"matrix_lib_typings": "./lib/index.d.ts", "matrix_lib_typings": "./lib/index.d.ts",
"matrix_i18n_extra_translation_funcs": [ "matrix_i18n_extra_translation_funcs": [
"newTranslatableError" "UserFriendlyError"
], ],
"scripts": { "scripts": {
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
@ -203,7 +203,7 @@
"jest-mock": "^29.2.2", "jest-mock": "^29.2.2",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"matrix-mock-request": "^2.5.0", "matrix-mock-request": "^2.5.0",
"matrix-web-i18n": "^1.3.0", "matrix-web-i18n": "^1.4.0",
"mocha-junit-reporter": "^2.2.0", "mocha-junit-reporter": "^2.2.0",
"node-fetch": "2", "node-fetch": "2",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",

View file

@ -187,6 +187,11 @@ declare global {
} }
interface Error { 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 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
fileName?: string; fileName?: string;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
@ -195,6 +200,22 @@ declare global {
columnNumber?: number; 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 // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
interface AudioWorkletProcessor { interface AudioWorkletProcessor {
readonly port: MessagePort; readonly port: MessagePort;

View file

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

View file

@ -34,7 +34,7 @@ import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import { _t } from "../../../languageHandler"; import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
@ -448,7 +448,15 @@ export const UserOptionsSection: React.FC<{
const inviter = new MultiInviter(roomId || ""); const inviter = new MultiInviter(roomId || "");
await inviter.invite([member.userId]).then(() => { await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(member.userId) !== "invited") { 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) { } catch (err) {

View file

@ -21,7 +21,7 @@ import { IContent } from "matrix-js-sdk/src/models/event";
import EditorModel from "./model"; import EditorModel from "./model";
import { Type } from "./parts"; import { Type } from "./parts";
import { Command, CommandCategories, getCommand } from "../SlashCommands"; import { Command, CommandCategories, getCommand } from "../SlashCommands";
import { ITranslatableError, _t, _td } from "../languageHandler"; import { UserFriendlyError, _t, _td } from "../languageHandler";
import Modal from "../Modal"; import Modal from "../Modal";
import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import QuestionDialog from "../components/views/dialogs/QuestionDialog"; import QuestionDialog from "../components/views/dialogs/QuestionDialog";
@ -65,7 +65,7 @@ export async function runSlashCommand(
): Promise<[content: IContent | null, success: boolean]> { ): Promise<[content: IContent | null, success: boolean]> {
const result = cmd.run(roomId, threadId, args); const result = cmd.run(roomId, threadId, args);
let messageContent: IContent | null = null; let messageContent: IContent | null = null;
let error = result.error; let error: any = result.error;
if (result.promise) { if (result.promise) {
try { try {
if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) { if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) {
@ -86,9 +86,8 @@ export async function runSlashCommand(
let errText; let errText;
if (typeof error === "string") { if (typeof error === "string") {
errText = error; errText = error;
} else if ((error as ITranslatableError).translatedMessage) { } else if (error instanceof UserFriendlyError) {
// Check for translatable errors (newTranslatableError) errText = error.translatedMessage;
errText = (error as ITranslatableError).translatedMessage;
} else if (error.message) { } else if (error.message) {
errText = error.message; errText = error.message;
} else { } 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.", "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", "Continue": "Continue",
"Use an identity server to invite by email. Manage in Settings.": "Use an identity server to invite by email. Manage in Settings.", "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", "Joins room with given address": "Joins room with given address",
"Leave room": "Leave room", "Leave room": "Leave room",
"Unrecognised room address: %(roomAlias)s": "Unrecognised room address: %(roomAlias)s", "Unrecognised room address: %(roomAlias)s": "Unrecognised room address: %(roomAlias)s",

View file

@ -46,21 +46,49 @@ counterpart.setSeparator("|");
const FALLBACK_LOCALE = "en"; const FALLBACK_LOCALE = "en";
counterpart.setFallbackLocale(FALLBACK_LOCALE); counterpart.setFallbackLocale(FALLBACK_LOCALE);
export interface ITranslatableError extends Error { interface ErrorOptions {
translatedMessage: string; // 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 * Used to rethrow an error with a user-friendly translatable message while maintaining
* with a translatedMessage property for use by the consumer. * access to that original underlying error. Downstream consumers can display the
* @param {string} message Message to translate. * `translatedMessage` property in the UI and inspect the underlying error with the
* @param {object} variables Variable substitutions, e.g { foo: 'bar' } * `cause` property.
* @returns {Error} The constructed error. *
* 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 { export class UserFriendlyError extends Error {
const error = new Error(message) as ITranslatableError; public readonly translatedMessage: string;
error.translatedMessage = _t(message, variables);
return error; 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 { export function getUserLanguage(): string {
@ -373,12 +401,18 @@ export function replaceByRegexes(text: string, mapping: IVariables | Tags): stri
} }
} }
if (!matchFoundSomewhere) { if (!matchFoundSomewhere) {
// The current regexp did not match anything in the input if (
// Missing matches is entirely possible because you might choose to show some variables only in the case // The current regexp did not match anything in the input. Missing
// of e.g. plurals. It's still a bit suspicious, and could be due to an error, so log it. // matches is entirely possible because you might choose to show some
// However, not showing count is so common that it's not worth logging. And other commonly unused variables // variables only in the case of e.g. plurals. It's still a bit
// here, if there are any. // suspicious, and could be due to an error, so log it. However, not
if (regexpString !== "%\\(count\\)s") { // 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}`); 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 * This function should be called *after* registering other translations data to
* ensure it overrides strings properly. * 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; const moduleTranslations = ModuleRunner.instance.allTranslations;
doRegisterTranslations(moduleTranslations); doRegisterTranslations(moduleTranslations);
@ -661,7 +699,7 @@ export async function registerCustomTranslations(): Promise<void> {
try { try {
let json: Optional<ICustomTranslations>; let json: Optional<ICustomTranslations>;
if (Date.now() >= cachedCustomTranslationsExpire) { if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) {
json = CustomTranslationOptions.lookupFn json = CustomTranslationOptions.lookupFn
? CustomTranslationOptions.lookupFn(lookupUrl) ? CustomTranslationOptions.lookupFn(lookupUrl)
: ((await (await fetch(lookupUrl)).json()) as ICustomTranslations); : ((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 { logger } from "matrix-js-sdk/src/logger";
import { IClientWellKnown } from "matrix-js-sdk/src/matrix"; import { IClientWellKnown } from "matrix-js-sdk/src/matrix";
import { _t, _td, newTranslatableError } from "../languageHandler"; import { _t, UserFriendlyError } from "../languageHandler";
import { makeType } from "./TypeUtils"; import { makeType } from "./TypeUtils";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
import { ValidatedServerConfig } from "./ValidatedServerConfig"; import { ValidatedServerConfig } from "./ValidatedServerConfig";
@ -147,7 +147,7 @@ export default class AutoDiscoveryUtils {
syntaxOnly = false, syntaxOnly = false,
): Promise<ValidatedServerConfig> { ): Promise<ValidatedServerConfig> {
if (!homeserverUrl) { if (!homeserverUrl) {
throw newTranslatableError(_td("No homeserver URL provided")); throw new UserFriendlyError("No homeserver URL provided");
} }
const wellknownConfig: IClientWellKnown = { 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 // 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". // 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."); 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"]; const hsResult = discoveryResult["m.homeserver"];
@ -221,9 +221,9 @@ export default class AutoDiscoveryUtils {
logger.error("Error determining preferred identity server URL:", isResult); logger.error("Error determining preferred identity server URL:", isResult);
if (isResult.state === AutoDiscovery.FAIL_ERROR) { if (isResult.state === AutoDiscovery.FAIL_ERROR) {
if (AutoDiscovery.ALL_ERRORS.indexOf(isResult.error as string) !== -1) { 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. } // else the error is not related to syntax - continue anyways.
// rewrite homeserver error since we don't care about problems // 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); logger.error("Error processing homeserver config:", hsResult);
if (!syntaxOnly || !AutoDiscoveryUtils.isLivelinessError(hsResult.error)) { if (!syntaxOnly || !AutoDiscoveryUtils.isLivelinessError(hsResult.error)) {
if (AutoDiscovery.ALL_ERRORS.indexOf(hsResult.error as string) !== -1) { 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. } // 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 // It should have been set by now, so check it
if (!preferredHomeserverName) { if (!preferredHomeserverName) {
logger.error("Failed to parse homeserver name from homeserver URL"); 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, { return makeType(ValidatedServerConfig, {

View file

@ -21,8 +21,25 @@ import {
ICustomTranslations, ICustomTranslations,
registerCustomTranslations, registerCustomTranslations,
setLanguage, setLanguage,
UserFriendlyError,
} from "../src/languageHandler"; } 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", () => { describe("languageHandler", () => {
afterEach(() => { afterEach(() => {
SdkConfig.unset(); SdkConfig.unset();
@ -33,38 +50,72 @@ describe("languageHandler", () => {
const str = "This is a test string that does not exist in the app."; 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 enOverride = "This is the English version of a custom string.";
const deOverride = "This is the German 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]: { [str]: {
en: enOverride, en: enOverride,
de: deOverride, 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 // Now test that they *are* being used
SdkConfig.add({
custom_translations_url: lookupUrl,
});
CustomTranslationOptions.lookupFn = fn;
await registerCustomTranslations();
await setLanguage("en"); await setLanguage("en");
expect(_t(str)).toEqual(enOverride); expect(_t(str)).toEqual(enOverride);
await setLanguage("de"); await setLanguage("de");
expect(_t(str)).toEqual(deOverride); 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: dependencies:
eslint-rule-composer "^0.3.0" 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" version "7.21.1"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd"
integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA== integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA==
@ -120,6 +120,16 @@
"@jridgewell/trace-mapping" "^0.3.17" "@jridgewell/trace-mapping" "^0.3.17"
jsesc "^2.5.1" 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": "@babel/generator@^7.7.2":
version "7.20.5" version "7.20.5"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.5.tgz#cb25abee3178adf58d6814b68517c62bdbfdda95" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.5.tgz#cb25abee3178adf58d6814b68517c62bdbfdda95"
@ -405,11 +415,16 @@
chalk "^2.0.0" chalk "^2.0.0"
js-tokens "^4.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" version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.2.tgz#dacafadfc6d7654c3051a66d6fe55b6cb2f2a0b3"
integrity sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ== 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": "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
version "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" 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/parser" "^7.18.10"
"@babel/types" "^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" version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.2.tgz#ac7e1f27658750892e815e60ae90f382a46d8e75" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.2.tgz#ac7e1f27658750892e815e60ae90f382a46d8e75"
integrity sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw== integrity sha512-ts5FFU/dSUPS13tv8XiEObDu9K+iagEKME9kAbaP7r0Y9KtZJZ+NGndDvWoRAYNpeWafbpFeki3q9QoMD6gxyw==
@ -1179,6 +1194,22 @@
debug "^4.1.0" debug "^4.1.0"
globals "^11.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": "@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" version "7.20.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.5.tgz#e206ae370b5393d94dfd1d04cd687cace53efa84" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.5.tgz#e206ae370b5393d94dfd1d04cd687cace53efa84"
@ -1197,7 +1228,16 @@
"@babel/helper-validator-identifier" "^7.19.1" "@babel/helper-validator-identifier" "^7.19.1"
to-fast-properties "^2.0.0" 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" version "7.21.2"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.2.tgz#92246f6e00f91755893c2876ad653db70c8310d1" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.2.tgz#92246f6e00f91755893c2876ad653db70c8310d1"
integrity sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw== integrity sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw==
@ -6528,10 +6568,10 @@ matrix-mock-request@^2.5.0:
dependencies: dependencies:
expect "^28.1.0" expect "^28.1.0"
matrix-web-i18n@^1.3.0: matrix-web-i18n@^1.4.0:
version "1.3.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-1.3.0.tgz#d85052635215173541f56ea1af0cbefd6e09ecb3" resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-1.4.0.tgz#f383a3ebc29d3fd6eb137d38cc4c3198771cc073"
integrity sha512-4QumouFjd4//piyRCtkfr24kjMPHkzNQNz09B1oEX4W3d4gdd5F+lwErqcQrys7Yl09U0S0iKCD8xPBRV178qg== integrity sha512-+NP2h4zdft+2H/6oFQ0i2PBm00Ei6HpUHke8rklgpe/yCABBG5Q7gIQdZoxazi0DXWWtcvvIfgamPZmkg6oRwA==
dependencies: dependencies:
"@babel/parser" "^7.18.5" "@babel/parser" "^7.18.5"
"@babel/traverse" "^7.18.5" "@babel/traverse" "^7.18.5"