FEATURE: Add "Recent Errors" tab and improved error modal (#2169)

This commit is contained in:
Michael Ficocelli
2025-06-16 19:43:21 -04:00
committed by GitHub
parent cf72937faf
commit 18f84396e2
17 changed files with 530 additions and 34 deletions

View File

@@ -0,0 +1,62 @@
import { Router } from "../ui/GameRoot";
import { SimplePage } from "@enums";
import { errorModalsAreSuppressed, ErrorRecord, ErrorState } from "./ErrorState";
let currentId = 0;
export const DisplayError = (
message: string,
errorType: string,
scriptName = "",
hostname: string = "",
pid: number = -1,
) => {
const errorPageOpen = Router.page() === SimplePage.RecentErrors;
if (!errorPageOpen) {
ErrorState.UnreadErrors++;
}
const prior = findExistingErrorCopy(message, hostname);
if (prior) {
prior.occurrences++;
prior.time = new Date();
if (pid !== -1) {
prior.pid = pid;
}
prior.server = hostname;
prior.message = message;
updateActiveError(prior);
} else {
ErrorState.Errors.unshift({
id: currentId++,
server: hostname,
errorType,
scriptName,
message,
pid,
occurrences: 1,
time: new Date(),
unread: !errorPageOpen,
});
while (ErrorState.Errors.length > 100) {
ErrorState.Errors.pop();
}
updateActiveError(ErrorState.Errors[0]);
}
};
function findExistingErrorCopy(message: string, hostname: string): ErrorRecord | null {
const serverAgnosticMessage = message.replaceAll(hostname, "<server>");
return (
ErrorState.Errors.find(
(e) => e.message.replaceAll(e.server, "<server>") === serverAgnosticMessage || e.message === message,
) ?? null
);
}
function updateActiveError(error: ErrorRecord): void {
if (!ErrorState.ActiveError && !errorModalsAreSuppressed()) {
ErrorState.ActiveError = error;
ErrorState.ErrorUpdate.emit(ErrorState.ActiveError);
}
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import { makeStyles } from "tss-react/mui";
import { Modal } from "../ui/React/Modal";
import { Box, Button, Typography } from "@mui/material/";
import { errorModalsAreSuppressed, type ErrorRecord, ErrorState, toggleSuppressErrorModals } from "./ErrorState";
import { Router } from "../ui/GameRoot";
import { SimplePage, ToastVariant } from "@enums";
import { useRerender } from "../ui/React/hooks";
import { OptionSwitch } from "../ui/React/OptionSwitch";
import { LogBoxEvents } from "../ui/React/LogBoxManager";
import { recentScripts } from "../Netscript/RecentScripts";
import { SnackbarEvents } from "../ui/React/Snackbar";
import { Settings } from "../Settings/Settings";
export function ErrorModal(): React.ReactElement {
const { classes } = useStyles();
const rerender = useRerender();
const [error, setError] = useState<ErrorRecord | null>(ErrorState.ActiveError);
useEffect(() => {
const listener = (newError: ErrorRecord) => {
if (newError.force || (Router.page() !== SimplePage.RecentErrors && !errorModalsAreSuppressed())) {
setError(newError);
rerender();
} else {
ErrorState.ActiveError = null;
setError(null);
}
};
return ErrorState.ErrorUpdate.subscribe(listener);
}, [rerender]);
const onClose = (force = false): void => {
ErrorState.ActiveError && (ErrorState.ActiveError.unread = false);
if (force || errorModalsAreSuppressed()) {
ErrorState.ActiveError = null;
setError(null);
} else {
const nextError = ErrorState.Errors.find((e) => e.unread) ?? null;
ErrorState.ActiveError = nextError;
setError(nextError);
}
ErrorState.UnreadErrors = ErrorState.Errors.filter((e) => e.unread).length;
};
const viewLogs = (): void => {
if (error === null) {
return;
}
const recentScript = recentScripts.find((script) => script.runningScript.pid === error.pid);
if (!recentScript) {
SnackbarEvents.emit(`No recent script found with pid ${error.pid}`, ToastVariant.INFO, 2000);
return;
}
onClose();
LogBoxEvents.emit(recentScript.runningScript);
};
const goToErrorPage = () => {
onClose(true);
Router.toPage(SimplePage.RecentErrors);
};
return (
<Modal open={!!error} onClose={() => onClose()}>
{error && (
<>
<Typography component="div">
<h2>{error.errorType} ERROR</h2>
{/* Add a zero-width space after each slash to allow clean wrapping. */}
<p style={{ whiteSpace: "pre-wrap" }}>{error.message.replaceAll("/", "/\u200B")}</p>
<p>
Script: {error.scriptName}
<br />
PID: {error.pid}
</p>
{!Settings.SuppressErrorModals && (
<OptionSwitch
checked={errorModalsAreSuppressed()}
onChange={(newValue) => toggleSuppressErrorModals(newValue)}
text="Suppress error modals (5 min)"
tooltip={
<>
If this is set, no error modals will be shown for the next five minutes, and only log errors to the
Recent Errors page.
</>
}
/>
)}
</Typography>
<Box className={classes.inlineFlexBox}>
<Button onClick={() => onClose()}>Close</Button>
<div>
<Button disabled={error.pid === -1} onClick={viewLogs}>
View Script Logs
</Button>
<Button onClick={goToErrorPage}>Errors Page</Button>
</div>
</Box>
</>
)}
</Modal>
);
}
const useStyles = makeStyles()(() => ({
inlineFlexBox: {
display: "inline-flex",
flexDirection: "row",
width: "100%",
justifyContent: "space-between",
},
}));

View File

@@ -0,0 +1,35 @@
import { EventEmitter } from "../utils/EventEmitter";
export type ErrorRecord = {
id: number;
server: string;
errorType: string;
message: string;
scriptName: string;
pid: number;
occurrences: number;
time: Date;
unread: boolean;
force?: boolean;
};
export const ErrorState = {
ErrorUpdate: new EventEmitter<[ErrorRecord]>(),
ActiveError: null as ErrorRecord | null,
Errors: [] as ErrorRecord[],
UnreadErrors: 0,
PreventModalsUntil: new Date(),
};
export function errorModalsAreSuppressed(): boolean {
return ErrorState.PreventModalsUntil.getTime() > Date.now();
}
export function toggleSuppressErrorModals(newValue: boolean, indefinite = false): void {
if (newValue) {
ErrorState.PreventModalsUntil = new Date(indefinite ? new Date("3000-01-01") : Date.now() + 1000 * 60 * 5);
ErrorState.ActiveError = null;
} else {
ErrorState.PreventModalsUntil = new Date();
}
}

View File

@@ -0,0 +1,120 @@
import React, { useEffect } from "react";
import { makeStyles } from "tss-react/mui";
import { type ErrorRecord, ErrorState } from "./ErrorState";
import { useRerender } from "../ui/React/hooks";
import { Typography, Tooltip } from "@mui/material";
import { Theme } from "@mui/material/styles";
export function RecentErrorsPage(): React.ReactElement {
const rerender = useRerender();
React.useEffect(() => {
const clearSubscription = ErrorState.ErrorUpdate.subscribe(rerender);
ErrorState.UnreadErrors = 0;
return () => {
clearSubscription();
ErrorState.UnreadErrors = 0;
};
}, [rerender]);
useEffect(() => {
ErrorState.Errors.forEach((error) => {
error.unread = false; // Mark all errors as read when the page is loaded
});
}, []);
const { classes } = useStyles();
const showError = (error: ErrorRecord): void => {
ErrorState.ErrorUpdate.emit({ ...error, force: true });
};
const formatMessage = (message: string): string => {
/**
* - Add a zero-width space after each slash to allow clean wrapping.
* - Replace 2+ newline characters with only 1 newline character to reduce the number of empty lines.
*/
return message.replaceAll("/", "/\u200B").replaceAll(/\n{2,}/g, "\n");
};
return (
<div>
<Typography component="div" sx={{ height: "100vh", overflowY: "auto", scrollbarWidth: "thin" }}>
<table className={classes.errorTable}>
<thead>
<tr>
<th className={classes.cellText}>Count</th>
<th className={classes.cellText}>Type</th>
<th className={classes.cellText}>Message</th>
<th className={classes.cellText}>Script</th>
<th className={classes.cellText}>Time</th>
</tr>
</thead>
<tbody>
{ErrorState.Errors.map((e, i) => (
<tr key={i} className={classes.errorRow} onClick={() => showError(e)}>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.occurrences}</div>
</td>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.errorType}</div>
</td>
<td>
<div className={classes.errorText} key={i}>
{formatMessage(e.message)}
</div>
</td>
<td className={classes.cellText}>
<div className={classes.small}>
<Tooltip title={<>{formatMessage(e.scriptName)}</>}>
<div style={{ textOverflow: "ellipsis", overflow: "auto" }}>{formatMessage(e.scriptName)}</div>
</Tooltip>
</div>
</td>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.time.toLocaleString()}</div>
</td>
</tr>
))}
</tbody>
</table>
</Typography>
</div>
);
}
const useStyles = makeStyles()((theme: Theme) => ({
errorTable: {
width: "100%",
maxWidth: "100%",
borderCollapse: "collapse",
},
errorRow: {
borderTop: `1px solid ${theme.colors.button}`,
"&:hover": {
backgroundColor: theme.colors.button,
},
},
cellText: {
verticalAlign: "top",
padding: "4px",
textAlign: "left",
},
errorText: {
margin: "4px",
color: "white",
textOverflow: "ellipsis",
whiteSpace: "pre-wrap",
lineClamp: "6",
lineHeight: 1.1,
overflowX: "auto",
maxHeight: "200px",
},
xsmall: {
maxWidth: "110px",
fontSize: "14px",
lineHeight: 1.2,
},
small: {
maxWidth: "200px",
},
}));

View File

@@ -3,8 +3,13 @@ import { OptionSwitch } from "../../ui/React/OptionSwitch";
import { Settings } from "../../Settings/Settings";
import { GameOptionsPage } from "./GameOptionsPage";
import { Player } from "@player";
import { toggleSuppressErrorModals } from "../../ErrorHandling/ErrorState";
export const GameplayPage = (): React.ReactElement => {
const toggleSuppressErrorModalsSetting = (newValue: boolean): void => {
Settings.SuppressErrorModals = newValue;
toggleSuppressErrorModals(newValue, true);
};
return (
<GameOptionsPage title="Gameplay">
<OptionSwitch
@@ -52,6 +57,17 @@ export const GameplayPage = (): React.ReactElement => {
text="Suppress TIX messages"
tooltip={<>If this is set, the stock market will never create any popup.</>}
/>
<OptionSwitch
checked={Settings.SuppressErrorModals}
onChange={toggleSuppressErrorModalsSetting}
text="Suppress error modals"
tooltip={
<>
If this is set, script errors will never create any popups. The errors can still be seen on the "Recent
Errors" tab in the Active Scripts page.
</>
}
/>
{Player.bladeburner && (
<OptionSwitch
checked={Settings.SuppressBladeburnerPopup}

View File

@@ -6,7 +6,7 @@ import { ScriptDeath } from "./ScriptDeath";
import { WorkerScript } from "./WorkerScript";
import { workerScripts } from "./WorkerScripts";
import { GetServer } from "../Server/AllServers";
import { GetAllServers, GetServer } from "../Server/AllServers";
import { AddRecentScript } from "./RecentScripts";
import { ITutorial } from "../InteractiveTutorial";
import { AlertEvents } from "../ui/React/AlertManager";
@@ -34,6 +34,16 @@ export function killWorkerScriptByPid(pid: number, killer?: WorkerScript): boole
return false;
}
export const killAllScripts = () => {
for (const server of GetAllServers()) {
for (const byPid of server.runningScriptMap.values()) {
for (const pid of byPid.keys()) {
killWorkerScriptByPid(pid);
}
}
}
};
function stopAndCleanUpWorkerScript(ws: WorkerScript): void {
// Only clean up once.
// Important: Only this function can set stopFlag!

View File

@@ -12,6 +12,7 @@ import {
assertAndSanitizeStyles,
} from "../JsonSchema/JSONSchemaAssertion";
import { mergePlayerDefinedKeyBindings, type PlayerDefinedKeyBindingsType } from "../utils/KeyBindingUtils";
import { toggleSuppressErrorModals } from "../ErrorHandling/ErrorState";
/**
* This function won't be able to catch **all** invalid hostnames. In order to validate a hostname properly, we need to
@@ -119,6 +120,8 @@ export const Settings = {
SaveGameOnFileSave: true,
/** Whether to hide the confirmation dialog for augmentation purchases. */
SuppressBuyAugmentationConfirmation: false,
/** Whether to hide the info dialog for script errors. */
SuppressErrorModals: false,
/** Whether to hide the dialog showing new faction invites. */
SuppressFactionInvites: false,
/** Whether to hide the dialog when the player receives a new message file. */
@@ -253,5 +256,8 @@ export const Settings = {
// Merge Settings.KeyBindings with DefaultKeyBindings.
mergePlayerDefinedKeyBindings(Settings.KeyBindings);
// Set up initial state for error modal suppression
toggleSuppressErrorModals(Settings.SuppressErrorModals, true);
},
};

View File

@@ -88,7 +88,7 @@ export function SidebarAccordion({
key_={key_}
icon={icon}
count={count}
active={active ?? page === key_}
active={active ?? (page === key_ || x.alternateKeys?.includes(page))}
clickFn={getClickFn(clickPage, key_)}
flash={flash === key_}
classes={classes}

View File

@@ -13,6 +13,7 @@ export interface ICreateProps {
icon: React.ReactElement["type"];
count?: number;
active?: boolean;
alternateKeys?: Page[];
}
export interface SidebarItemProps extends ICreateProps {

View File

@@ -68,6 +68,7 @@ import {
CurrentKeyBindings,
} from "../../utils/KeyBindingUtils";
import { throwIfReachable } from "../../utils/helpers/throwIfReachable";
import { ErrorState } from "../../ErrorHandling/ErrorState";
const RotatedDoubleArrowIcon = React.forwardRef(function RotatedDoubleArrowIcon(
props: { color: "primary" | "secondary" | "error" },
@@ -148,6 +149,7 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement {
const augmentationCount = Player.queuedAugmentations.length;
const invitationsCount = Player.factionInvitations.filter((f) => !InvitationsSeen.has(f)).length;
const programCount = getAvailableCreatePrograms().length - ProgramsSeen.size;
const errorCount = ErrorState.UnreadErrors;
const canOpenFactions =
Player.factionInvitations.length > 0 ||
@@ -339,7 +341,12 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement {
items={[
{ key_: Page.Terminal, icon: LastPageIcon },
{ key_: Page.ScriptEditor, icon: CreateIcon },
{ key_: Page.ActiveScripts, icon: StorageIcon },
{
key_: Page.ActiveScripts,
icon: StorageIcon,
count: errorCount,
alternateKeys: [Page.RecentErrors, Page.RecentlyKilledScripts],
},
{ key_: Page.CreateProgram, icon: BugReportIcon, count: programCount },
canStaneksGift && { key_: Page.StaneksGift, icon: DeveloperBoardIcon },
]}

View File

@@ -2,30 +2,95 @@
* Root React Component for the "Active Scripts" UI page. This page displays
* and provides information about all of the player's scripts that are currently running
*/
import React, { useState } from "react";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import React, { useState, useEffect } from "react";
import { Button, Tabs, Tab } from "@mui/material";
import { ActiveScriptsPage } from "./ActiveScriptsPage";
import { RecentScriptsPage } from "./RecentScriptsPage";
import { RecentErrorsPage } from "../../ErrorHandling/RecentErrorsPage";
import { useRerender } from "../React/hooks";
import { errorModalsAreSuppressed, ErrorState, toggleSuppressErrorModals } from "../../ErrorHandling/ErrorState";
import { OptionSwitch } from "../React/OptionSwitch";
import { killAllScripts } from "../../Netscript/killWorkerScript";
import { SimplePage } from "@enums";
import { Router } from "../GameRoot";
import { Settings } from "../../Settings/Settings";
export function ActiveScriptsRoot(): React.ReactElement {
const [tab, setTab] = useState<"active" | "recent">("active");
type ActiveScriptsTab = SimplePage.ActiveScripts | SimplePage.RecentlyKilledScripts | SimplePage.RecentErrors;
export type ComponentProps = {
page: ActiveScriptsTab;
};
export function ActiveScriptsRoot({ page }: ComponentProps): React.ReactElement {
const [tab, setTab] = useState<ActiveScriptsTab>(page);
useRerender(400);
function handleChange(event: React.SyntheticEvent, tab: "active" | "recent"): void {
useEffect(() => {
if (ErrorState.UnreadErrors > 0) {
handleChange(null, SimplePage.RecentErrors);
}
}, []);
function handleChange(
__event: React.SyntheticEvent | null,
tab: SimplePage.ActiveScripts | SimplePage.RecentlyKilledScripts | SimplePage.RecentErrors,
): void {
setTab(tab);
Router.toPage(tab);
}
function errorTabText(): string {
if (!ErrorState.UnreadErrors || tab === SimplePage.RecentErrors) {
return "Recent Errors";
}
return `Recent Errors (${ErrorState.UnreadErrors})`;
}
return (
<>
<Tabs variant="fullWidth" value={tab} onChange={handleChange} sx={{ minWidth: "fit-content", maxWidth: "25%" }}>
<Tab label={"Active"} value={"active"} />
<Tab label={"Recently Killed"} value={"recent"} />
</Tabs>
<div style={{ display: "flex", alignItems: "center" }}>
<Tabs
value={tab}
onChange={handleChange}
sx={{
minHeight: "fit-content",
"& .MuiButtonBase-root.MuiTab-root": {
margin: 0,
padding: "10px",
whiteSpace: "pre",
minHeight: "40px",
},
}}
>
<Tab label={"Active"} value={SimplePage.ActiveScripts} />
<Tab label={"Recently Killed"} value={SimplePage.RecentlyKilledScripts} />
<Tab label={errorTabText()} value={SimplePage.RecentErrors} />
</Tabs>
{Settings.SuppressErrorModals ? (
<div style={{ width: "15%" }}></div>
) : (
<OptionSwitch
checked={errorModalsAreSuppressed()}
onChange={(newValue) => toggleSuppressErrorModals(newValue)}
text="Suppress error modals (5 min)"
tooltip={
<>
If this is set, no error modals will be shown for the next five minutes, and only log errors to the
Recent Errors page.
</>
}
wrapperStyles={{ marginLeft: "20px" }}
/>
)}
<Button color="error" onClick={killAllScripts} sx={{ margin: 0 }}>
Kill All Scripts
</Button>
</div>
{tab === "active" && <ActiveScriptsPage />}
{tab === "recent" && <RecentScriptsPage />}
{tab === SimplePage.ActiveScripts && <ActiveScriptsPage />}
{tab === SimplePage.RecentlyKilledScripts && <RecentScriptsPage />}
{tab === SimplePage.RecentErrors && <RecentErrorsPage />}
</>
);
}

View File

@@ -13,6 +13,8 @@ export enum ToastVariant {
*/
export enum SimplePage {
ActiveScripts = "Active Scripts",
RecentlyKilledScripts = "Recently Killed Scripts",
RecentErrors = "Recent Errors",
Augmentations = "Augmentations",
Bladeburner = "Bladeburner",
City = "City",

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import { Box, Typography } from "@mui/material";
import { Theme } from "@mui/material/styles";
import { makeStyles } from "tss-react/mui";
@@ -7,7 +7,7 @@ import { Player } from "@player";
import { installAugmentations } from "../Augmentation/AugmentationHelpers";
import { saveObject } from "../SaveObject";
import { onExport } from "../ExportBonus";
import { CompletedProgramName, LocationName } from "@enums";
import { CompletedProgramName, LocationName, SimplePage } from "@enums";
import { ITutorial, iTutorialStart } from "../InteractiveTutorial";
import { InteractiveTutorialRoot } from "./InteractiveTutorial/InteractiveTutorialRoot";
import { ITutorialEvents } from "./InteractiveTutorial/ITutorialEvents";
@@ -18,7 +18,7 @@ import { GetAllServers } from "../Server/AllServers";
import { StockMarket } from "../StockMarket/StockMarket";
import type { ComplexPage } from "./Enums";
import type { PageWithContext, IRouter, PageContext } from "./Router";
import type { IRouter, PageContext, PageWithContext } from "./Router";
import { Page } from "./Router";
import { Overview } from "./React/Overview";
import { SidebarRoot } from "../Sidebar/ui/SidebarRoot";
@@ -78,6 +78,7 @@ import { Settings } from "../Settings/Settings";
import { isBitNodeFinished } from "../BitNode/BitNodeUtils";
import { exceptionAlert } from "../utils/helpers/exceptionAlert";
import { SpecialServers } from "../Server/data/SpecialServers";
import { ErrorModal } from "../ErrorHandling/ErrorModal";
import { DocumentationPopUp } from "../Documentation/ui/DocumentationPopUp";
const htmlLocation = location;
@@ -281,7 +282,15 @@ export function GameRoot(): React.ReactElement {
break;
}
case Page.ActiveScripts: {
mainPage = <ActiveScriptsRoot />;
mainPage = <ActiveScriptsRoot page={SimplePage.ActiveScripts} />;
break;
}
case Page.RecentlyKilledScripts: {
mainPage = <ActiveScriptsRoot page={SimplePage.RecentlyKilledScripts} />;
break;
}
case Page.RecentErrors: {
mainPage = <ActiveScriptsRoot page={SimplePage.RecentErrors} />;
break;
}
case Page.Hacknet: {
@@ -441,6 +450,7 @@ export function GameRoot(): React.ReactElement {
<Unclickable />
<LogBoxManager hidden={hidePopups} />
<AlertManager hidden={hidePopups} />
<ErrorModal />
<PromptManager hidden={hidePopups} />
<FactionInvitationManager hidden={hidePopups} />
<Snackbar hidden={hidePopups} />

View File

@@ -7,6 +7,7 @@ type OptionSwitchProps = {
onChange: (newValue: boolean, error?: string) => void;
text: React.ReactNode;
tooltip: React.ReactNode;
wrapperStyles?: React.CSSProperties;
};
export function OptionSwitch({
@@ -15,6 +16,7 @@ export function OptionSwitch({
onChange,
text,
tooltip,
wrapperStyles,
}: OptionSwitchProps): React.ReactElement {
const [value, setValue] = useState(checked);
@@ -29,7 +31,7 @@ export function OptionSwitch({
}, [checked]);
return (
<>
<div style={wrapperStyles}>
<FormControlLabel
disabled={disabled}
control={<Switch checked={value} onChange={handleSwitchChange} />}
@@ -39,7 +41,6 @@ export function OptionSwitch({
</Tooltip>
}
/>
<br />
</>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { basicErrorMessage } from "../Netscript/ErrorMessages";
import { ScriptDeath } from "../Netscript/ScriptDeath";
import type { WorkerScript } from "../Netscript/WorkerScript";
import { dialogBoxCreate } from "../ui/React/DialogBox";
import { getErrorMessageWithStackAndCause } from "./ErrorHelper";
import { getErrorMessageWithStackAndCause, parseUnknownError } from "./ErrorHelper";
import { DisplayError } from "../ErrorHandling/DisplayError";
/** Generate an error dialog when workerscript is known */
export function handleUnknownError(e: unknown, ws: WorkerScript | null = null, initialText = "") {
@@ -10,12 +11,36 @@ export function handleUnknownError(e: unknown, ws: WorkerScript | null = null, i
// No dialog for ScriptDeath
return;
}
const errorDetails = parseUnknownError(e);
if (ws && typeof e === "string") {
const headerText = basicErrorMessage(ws, "", "");
if (!e.includes(headerText)) e = basicErrorMessage(ws, e);
/**
* - Attempt to strip out the error type, if present.
* - Extract error text by skipping:
* - Error type
* - Script name and PID
*
* Error example:
* "RUNTIME ERROR\ntest.js@home (PID - 1)\n\ngetServer: Invalid hostname: 'invalid'\n\nStack:\ntest.js:L5@main"
*
* - errorType: "RUNTIME"
* - errorText: "getServer: Invalid hostname: 'invalid'\n\nStack:\ntest.js:L5@main"
*/
const errorType = e.match(/^(\w+) ERROR/)?.[1];
if (errorType) {
const errorText = e.split(/\n/).slice(3).join("\n");
DisplayError(initialText + errorText, errorType, ws.scriptRef.filename, ws.hostname, ws.pid);
return;
}
DisplayError(initialText + e, "RUNTIME", ws.scriptRef.filename, ws.hostname, ws.pid);
} else if (e instanceof SyntaxError) {
const msg = `${e.message} (sorry we can't be more helpful)`;
e = ws ? basicErrorMessage(ws, msg, "SYNTAX") : `SYNTAX ERROR:\n\n${msg}`;
DisplayError(
initialText + msg + (errorDetails.stack ?? ""),
"SYNTAX",
ws?.scriptRef?.filename,
ws?.hostname,
ws?.pid,
);
} else if (e instanceof Error) {
// Ignore any cancellation errors from Monaco that get here
if (e.name === "Canceled" && e.message === "Canceled") {
@@ -32,15 +57,13 @@ export function handleUnknownError(e: unknown, ws: WorkerScript | null = null, i
*/
console.error(e);
const msg = getErrorMessageWithStackAndCause(e);
e = ws ? basicErrorMessage(ws, msg) : `RUNTIME ERROR:\n\n${msg}`;
}
if (typeof e !== "string") {
DisplayError(initialText + msg, getErrorType(e.stack) ?? "RUNTIME", ws?.scriptRef?.filename, ws?.hostname, ws?.pid);
} else if (typeof e !== "string") {
console.error("Unexpected error:", e);
const msg = `Unexpected type of error thrown. This error was likely thrown manually within a script.
Error has been logged to the console.\n\nType of error: ${typeof e}\nValue of error: ${e}`;
e = ws ? basicErrorMessage(ws, msg, "UNKNOWN") : msg;
DisplayError(msg, "UNKNOWN", ws?.scriptRef?.filename, ws?.hostname, ws?.pid);
}
dialogBoxCreate(initialText + String(e));
}
/** Use this handler to handle the error when we call getSaveData function or getSaveInfo function */
@@ -55,3 +78,22 @@ export function handleGetSaveDataInfoError(error: unknown, fromGetSaveInfo = fal
}
dialogBoxCreate(errorMessage);
}
function getErrorType(e = ""): string | undefined {
if (e.toLowerCase().includes("typeerror")) {
return "TYPE";
}
if (e.toLowerCase().includes("syntaxerror")) {
return "SYNTAX";
}
if (e.toLowerCase().includes("referenceerror")) {
return "REFERENCE";
}
if (e.toLowerCase().includes("rangeerror")) {
return "RANGE";
}
// Check if the first line contains an error type
const match = e.match(/^\s*([A-Z]+)\s+ERROR/);
return match?.[1];
}

View File

@@ -82,7 +82,7 @@ export function getErrorMessageWithStackAndCause(error: unknown, prefix = ""): s
const errorData = parseUnknownError(error);
let errorMessage = `${prefix}${errorData.errorAsString}`;
if (errorData.stack) {
errorMessage += `\nStack: ${errorData.stack}`;
errorMessage += `\n\nStack: ${errorData.stack}`;
}
if (errorData.causeAsString) {
errorMessage += `\nError cause: ${errorData.causeAsString}`;

View File

@@ -16,6 +16,7 @@ import { SpecialServers } from "../../../src/Server/data/SpecialServers";
import { WorkerScript } from "../../../src/Netscript/WorkerScript";
import { NetscriptFunctions } from "../../../src/NetscriptFunctions";
import type { PositiveInteger } from "../../../src/types";
import { ErrorState } from "../../../src/ErrorHandling/ErrorState";
declare const importActual: (typeof EvaluatorConfig)["doImport"];
@@ -118,6 +119,7 @@ const runOptions = {
describe("runScript and runScriptFromScript", () => {
let alertDelete: () => void;
let alerted: Promise<unknown>;
let errorShown: Promise<unknown>;
beforeEach(() => {
setupBasicTestingEnvironment();
@@ -127,6 +129,9 @@ describe("runScript and runScriptFromScript", () => {
alerted = new Promise((resolve) => {
alertDelete = AlertEvents.subscribe((x) => resolve(x));
});
errorShown = new Promise((resolve) => {
ErrorState.ErrorUpdate.subscribe((x) => resolve(x));
});
});
afterEach(() => {
alertDelete();
@@ -215,7 +220,7 @@ describe("runScript and runScriptFromScript", () => {
throw new Error(`Invalid worker script`);
}
const result = await Promise.race([
alerted,
errorShown,
new Promise<void>((resolve) => (workerScript.atExit = new Map([["default", resolve]]))),
]);
expect(result).toBeDefined();