Files
NetMesh/electron/bridges/mainProcessErrorGuards.test.cjs

254 lines
7.5 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
classifyProcessError,
createProcessErrorController,
installProcessErrorHandlers,
isNonFatalNetworkError,
} = require("./processErrorGuards.cjs");
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
true,
);
});
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
true,
);
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
true,
);
});
test("treats Node socket error codes as non-fatal network errors", () => {
const err = new Error("socket reset");
err.code = "ECONNRESET";
assert.equal(isNonFatalNetworkError(err), true);
const dnsErr = new Error("dns failed");
dnsErr.code = "ENOTFOUND";
assert.equal(isNonFatalNetworkError(dnsErr), true);
});
test("keeps non-network errors fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("Something else broke")),
false,
);
});
test("generic startup exceptions stay fatal before the app is up", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: false,
});
assert.equal(result.action, "fatal");
});
test("generic runtime exceptions are suppressed after startup", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: true,
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("generic runtime promise rejections are also suppressed after startup", () => {
const result = classifyProcessError(new Error("promise boom"), {
runtimeStarted: true,
origin: "unhandledRejection",
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("controller keeps startup strict until the main window is actually shown", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("controller becomes strict again while recreating a missing main window", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: false });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("startup-period errors stay fatal while recreating the main window", () => {
const fakeProcess = new EventEmitter();
const fatals = [];
const controller = createProcessErrorController({
captureError() {},
onFatalError(err) {
fatals.push(err.message);
throw err;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.completeMainWindowStartup({ windowShown: true });
controller.beginMainWindowStartup();
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
}, /recreate boom/);
assert.deepEqual(fatals, ["recreate boom"]);
});
test("fatal startup failures uninstall listeners and keep throwing", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const fatals = [];
let uninstall = null;
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err.message);
uninstall?.();
throw err;
},
logError() {},
logWarn() {},
});
uninstall = installProcessErrorHandlers(fakeProcess, controller);
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("startup boom"));
}, /startup boom/);
assert.deepEqual(fatals, ["startup boom"]);
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
});
test("installed handlers suppress runtime failures after startup", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const errors = [];
const warnings = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
throw err;
},
logError(...args) {
errors.push(args.map(String).join(" "));
},
logWarn(...args) {
warnings.push(args.map(String).join(" "));
},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
assert.deepEqual(captured, [
["uncaughtException", "runtime boom"],
["unhandledRejection", "runtime rejection"],
]);
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
assert.equal(warnings.length, 0);
});
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
const captured = [];
const fatals = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err);
},
logError() {},
logWarn() {},
});
controller.handleUnhandledRejection(new Error("startup rejection"));
assert.equal(fatals.length, 1);
assert.equal(fatals[0].__fromUnhandledRejection, true);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
controller.handleUncaughtException(fatals[0]);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
});
test("benign stream teardown errors are ignored by the installed handlers", () => {
const fakeProcess = new EventEmitter();
let captureCount = 0;
let fatalCount = 0;
const controller = createProcessErrorController({
captureError() {
captureCount += 1;
},
onFatalError() {
fatalCount += 1;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
const err = new Error("broken pipe");
err.code = "EPIPE";
fakeProcess.emit("uncaughtException", err);
assert.equal(captureCount, 0);
assert.equal(fatalCount, 0);
});
test("controller suppresses wrapped network errors from err.cause", () => {
const err = new Error("request failed");
err.cause = new Error("net::ERR_NETWORK_CHANGED");
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});
test("controller suppresses ssh-style errors with a level property", () => {
const err = new Error("connection lost before handshake");
err.level = "client-socket";
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});