@itwin/core-backend
Version:
iTwin.js backend components
449 lines • 23.2 kB
JavaScript
import * as sinon from "sinon";
import { expect } from "chai";
import { BentleyError, IModelStatus, ITwinError } from "@itwin/core-bentley";
import { serializeIpcError } from "@itwin/core-common";
import { IpcHandler, IpcHost } from "../IpcHost";
class OuterError extends Error {
originalError;
context;
errors;
constructor(originalError) {
super("outer");
this.originalError = originalError;
this.context = { cause: originalError };
this.errors = [new Error("array-child-1"), new Error("array-child-2")];
}
}
class MockIpcHandler extends IpcHandler {
get channelName() { return "mock-channel"; }
mockMethod() {
return "mock-value";
}
throwNestedError() {
const inner = new Error("inner-message");
throw new OuterError(inner);
}
throwCircularError() {
const err = new Error("circular");
err.cause = err; // circular reference
throw err;
}
throwErrorWithNativeCause() {
throw new Error("native-cause-outer", { cause: new Error("native-cause-inner") });
}
throwBentleyErrorWithFunctionMetaData() {
throw new BentleyError(IModelStatus.BadArg, "bentley-fn-meta", () => ({ detail: "computed" }));
}
throwBentleyErrorWithObjectMetaData() {
throw new BentleyError(IModelStatus.BadArg, "bentley-obj-meta", { detail: "static" });
}
throwNestedBentleyErrorWithFunctionMetaData() {
const inner = new BentleyError(IModelStatus.BadArg, "inner-bentley", () => ({ detail: "nested-computed" }));
const outer = new Error("outer");
outer.cause = inner;
throw outer;
}
throwBentleyErrorWithNestedBentleyErrorMetaData() {
const inner = new BentleyError(IModelStatus.BadArg, "inner-bentley-in-metadata", { detail: "inner-value" });
throw new BentleyError(IModelStatus.BadArg, "bentley-nested-bentley-meta", { nested: inner });
}
throwErrorWithFunctionProperty() {
const err = new Error("fn-prop");
err.retry = () => { };
throw err;
}
throwErrorWithSymbolProperty() {
const err = new Error("sym-prop");
err[Symbol("tag")] = "should-be-dropped";
throw err;
}
throwErrorWithDateProperty() {
const err = new Error("date-prop");
err.timestamp = new Date("2024-01-01");
throw err;
}
throwErrorWithClassInstanceProperty() {
const err = new Error("class-instance-prop");
err.request = new URL("https://example.com"); // arbitrary non-plain class instance
throw err;
}
throwErrorWithMixedArray() {
const err = new Error("mixed-array");
err.items = ["string-value", 42, new Date("2024-01-01"), new URL("https://example.com"), new Error("array-error")];
throw err;
}
throwErrorWithNestedArrayProperty() {
const err = new Error("nested-array");
err.matrix = [[1, 2], [3, 4]];
throw err;
}
throwBentleyErrorWithMapMetaData() {
throw new BentleyError(IModelStatus.BadArg, "bentley-map-meta", new Map([["detail", "map-value"]]));
}
throwBentleyErrorWithCollidingMapMetaData() {
// Numeric key `1` and string key `"1"` stringify to the same "1", so they'd collide without disambiguation.
throw new BentleyError(IModelStatus.BadArg, "bentley-colliding-map-meta", new Map([[1, "num-one"], ["1", "str-one"]]));
}
throwBentleyErrorWithSetMetaData() {
throw new BentleyError(IModelStatus.BadArg, "bentley-set-meta", new Set(["tag1", "tag2"]));
}
throwErrorWithSelfReferencingArray() {
const err = new Error("self-referencing-array");
const arr = [];
arr.push(arr); // array that contains itself
err.items = arr;
throw err;
}
#privateFunction() { }
}
describe("IpcHost", () => {
let socket;
beforeEach(async () => {
socket = {
send: sinon.stub(),
addListener: sinon.stub(),
removeListener: sinon.stub(),
handle: sinon.stub(),
};
await IpcHost.startup({ ipcHost: { socket } });
});
afterEach(() => {
IpcHost._nextInvokeId = 0;
IpcHost._pendingInvokes.clear();
});
describe("IpcHandler", () => {
let handler;
beforeEach(() => {
MockIpcHandler.register();
const handleCall = socket.handle.getCalls().find((call) => call.args[0] === "itwin.mock-channel");
expect(handleCall).to.not.be.undefined;
handler = handleCall.args[1];
});
it("should call public methods", async () => {
const ipcReturn = await handler(undefined, "mockMethod");
expect(ipcReturn.result).to.equal("mock-value");
expect(ipcReturn.error).to.be.undefined;
});
it("should not call private methods", async () => {
const ipcReturn = await handler(undefined, "#privateFunction");
expect(ipcReturn.result).to.be.undefined;
expect(ipcReturn.error).to.not.be.undefined;
});
it("should not call methods inherited from Object", async () => {
const ipcReturn = await handler(undefined, "toString");
expect(ipcReturn.result).to.be.undefined;
expect(ipcReturn.error).to.not.be.undefined;
});
it("should serialize nested Error properties preserving message and stack", async () => {
const ipcReturn = await handler(undefined, "throwNestedError");
const error = ipcReturn.error;
// Top-level error
expect(error.message).to.equal("outer");
expect(error.stack).to.be.a("string");
// Direct Error property
expect(error.originalError.message).to.equal("inner-message");
expect(error.originalError.stack).to.be.a("string");
// Error nested inside a plain-object property
expect(error.context.cause.message).to.equal("inner-message");
expect(error.context.cause.stack).to.be.a("string");
// Array of Errors stays an array
expect(Array.isArray(error.errors)).to.be.true;
expect(error.errors[0].message).to.equal("array-child-1");
expect(error.errors[1].message).to.equal("array-child-2");
});
it("should omit stack on nested Errors when IpcHost.noStack is set", async () => {
const originalNoStack = IpcHost.noStack;
IpcHost.noStack = true;
try {
const ipcReturn = await handler(undefined, "throwNestedError");
const error = ipcReturn.error;
expect(error.stack).to.be.undefined;
expect(error.originalError.stack).to.be.undefined;
expect(error.context.cause.stack).to.be.undefined;
expect(error.errors[0].stack).to.be.undefined;
expect(error.errors[1].stack).to.be.undefined;
}
finally {
IpcHost.noStack = originalNoStack;
}
});
it("should not infinitely recurse on circular Error references", async () => {
const ipcReturn = await handler(undefined, "throwCircularError");
const error = ipcReturn.error;
expect(error.message).to.equal("circular");
expect(error.cause).to.be.undefined;
});
it("should serialize native Error.cause even though it is non-enumerable", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithNativeCause");
const error = ipcReturn.error;
expect(error.message).to.equal("native-cause-outer");
expect(error.cause).to.not.be.undefined;
expect(error.cause.message).to.equal("native-cause-inner");
expect(error.cause.stack).to.be.a("string");
});
it("should drop _metaData and expose loggingMetadata when BentleyError has a GetMetaDataFunction", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithFunctionMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-fn-meta");
// _metaData is a function — must not survive serialization
expect(error._metaData).to.be.undefined;
// loggingMetadata should be the resolved object
expect(error.loggingMetadata).to.deep.equal({ detail: "computed" });
});
it("should drop _metaData and expose loggingMetadata when BentleyError has an object metaData", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithObjectMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-obj-meta");
expect(error._metaData).to.be.undefined;
expect(error.loggingMetadata).to.deep.equal({ detail: "static" });
});
it("should drop function-valued _metaData on a nested BentleyError and still expose loggingMetadata", async () => {
const ipcReturn = await handler(undefined, "throwNestedBentleyErrorWithFunctionMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("outer");
// The nested BentleyError inside .cause must also have _metaData stripped and loggingMetadata populated
expect(error.cause).to.not.be.undefined;
expect(error.cause.message).to.equal("inner-bentley");
expect(error.cause._metaData).to.be.undefined;
expect(error.cause.loggingMetadata).to.deep.equal({ detail: "nested-computed" });
});
it("should drop arbitrary function-valued properties from a thrown Error", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithFunctionProperty");
const error = ipcReturn.error;
expect(error.message).to.equal("fn-prop");
expect(error.retry).to.be.undefined;
});
it("should not include symbol-keyed properties from a thrown Error", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithSymbolProperty");
const error = ipcReturn.error;
expect(error.message).to.equal("sym-prop");
// { ...e } copies enumerable own symbol-keyed properties too.
// The serializer explicitly strips them (they cannot be structured-cloned).
expect(Object.getOwnPropertySymbols(error)).to.have.length(0);
});
it("should preserve Date properties", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithDateProperty");
const error = ipcReturn.error;
expect(error.message).to.equal("date-prop");
expect(error.timestamp).to.deep.equal(new Date("2024-01-01"));
});
it("should drop non-plain class instance properties", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithClassInstanceProperty");
const error = ipcReturn.error;
expect(error.message).to.equal("class-instance-prop");
expect(error.request).to.be.undefined;
});
it("should sanitize mixed arrays: preserve primitives and Dates, drop non-plain class instances, recurse into Errors", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithMixedArray");
const error = ipcReturn.error;
expect(error.message).to.equal("mixed-array");
expect(Array.isArray(error.items)).to.be.true;
expect(error.items[0]).to.equal("string-value"); // primitive: preserved
expect(error.items[1]).to.equal(42); // primitive: preserved
expect(error.items[2]).to.deep.equal(new Date("2024-01-01")); // Date: preserved
expect(error.items[3]).to.be.undefined; // class instance: dropped (→ undefined)
expect(error.items[4].message).to.equal("array-error"); // Error: recursed
});
it("should preserve nested arrays (array-of-arrays)", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithNestedArrayProperty");
const error = ipcReturn.error;
expect(error.message).to.equal("nested-array");
expect(error.matrix).to.deep.equal([[1, 2], [3, 4]]);
});
it("should not infinitely recurse on a self-referencing array", async () => {
const ipcReturn = await handler(undefined, "throwErrorWithSelfReferencingArray");
const error = ipcReturn.error;
expect(error.message).to.equal("self-referencing-array");
expect(error.items[0]).to.be.undefined; // cycle detected: dropped rather than recursing forever
});
it("should normalize Map-valued loggingMetadata to a JSON-safe plain object (survives every transport)", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithMapMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-map-meta");
expect(error.loggingMetadata).to.deep.equal({ detail: "map-value" });
});
it("should disambiguate Map-valued loggingMetadata keys that stringify to the same value", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithCollidingMapMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-colliding-map-meta");
expect(error.loggingMetadata).to.deep.equal({ "1": "num-one", "1#1": "str-one" });
});
it("should preserve iTwinErrorId/loggingMetadata of a BentleyError nested inside loggingMetadata", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithNestedBentleyErrorMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-nested-bentley-meta");
const nested = error.loggingMetadata.nested;
expect(nested.message).to.equal("inner-bentley-in-metadata");
expect(nested.iTwinErrorId).to.deep.equal({ scope: BentleyError.iTwinErrorScope, key: nested.iTwinErrorId.key });
expect(nested.loggingMetadata).to.deep.equal({ detail: "inner-value" });
});
it("should normalize Set-valued loggingMetadata to a JSON-safe array (survives every transport)", async () => {
const ipcReturn = await handler(undefined, "throwBentleyErrorWithSetMetaData");
const error = ipcReturn.error;
expect(error.message).to.equal("bentley-set-meta");
expect(error.loggingMetadata).to.deep.equal(["tag1", "tag2"]);
});
});
describe("IpcHost.invoke", () => {
/** Simulates the frontend: captures per-request listeners and provides a `respond` helper by call index. */
function mockFrontend() {
const listeners = new Map();
socket.addListener.callsFake((ch, fn) => {
listeners.set(ch, fn);
return () => listeners.delete(ch);
});
return {
listeners,
responseChannel: (callIndex) => socket.send.getCall(callIndex).args[1],
respond: (callIndex, result) => {
const responseChannel = socket.send.getCall(callIndex).args[1];
const listener = listeners.get(responseChannel);
if (!listener)
throw new Error(`No listener found for response channel ${responseChannel}`);
listener(undefined, result);
},
};
}
it("should resolve with the frontend's response", async () => {
const frontend = mockFrontend();
const promise = IpcHost.invoke("ch", "a", "b");
frontend.respond(0, "hello");
expect(await promise).to.equal("hello");
// the pending-invoke entry must be removed so the map does not grow unboundedly
expect(IpcHost._pendingInvokes.size).to.equal(0);
});
it("should route concurrent invokes to the correct caller", async () => {
const frontend = mockFrontend();
const p1 = IpcHost.invoke("ch");
const p2 = IpcHost.invoke("ch");
// respond out of order
frontend.respond(1, "second");
frontend.respond(0, "first");
expect(await p1).to.equal("first");
expect(await p2).to.equal("second");
});
it("should remove the response listener after a response", async () => {
const frontend = mockFrontend();
const promise = IpcHost.invoke("ch");
const responseChannel = frontend.responseChannel(0);
expect(frontend.listeners.has(responseChannel)).to.be.true;
frontend.respond(0, "done");
await promise;
expect(frontend.listeners.has(responseChannel)).to.be.false;
});
it("should reject pending invokes when IpcHost is shut down", async () => {
const frontend = mockFrontend();
const promise = IpcHost.invoke("ch");
const responseChannel = frontend.responseChannel(0);
// attach handler immediately so the rejection is observed (and not "unhandled")
const caught = promise.then(() => undefined, (e) => e);
await IpcHost.shutdown();
const err = await caught;
expect(err).to.be.instanceOf(Error);
expect(err.message).to.contain("shut down");
// the listener must be cleaned up so it does not leak past shutdown
expect(frontend.listeners.has(responseChannel)).to.be.false;
});
it("should reject all pending invokes when IpcHost is shut down with 2+ concurrent invokes, removing all listeners", async () => {
const frontend = mockFrontend();
const p1 = IpcHost.invoke("ch1");
const p2 = IpcHost.invoke("ch2");
const responseChannel1 = frontend.responseChannel(0);
const responseChannel2 = frontend.responseChannel(1);
// attach handlers immediately so the rejections are observed (and not "unhandled")
const caught1 = p1.then(() => undefined, (e) => e);
const caught2 = p2.then(() => undefined, (e) => e);
expect(IpcHost._pendingInvokes.size).to.equal(2);
await IpcHost.shutdown();
const err1 = await caught1;
const err2 = await caught2;
expect(err1).to.be.instanceOf(Error);
expect(err1.message).to.contain("shut down");
expect(err2).to.be.instanceOf(Error);
expect(err2.message).to.contain("shut down");
expect(frontend.listeners.has(responseChannel1)).to.be.false;
expect(frontend.listeners.has(responseChannel2)).to.be.false;
expect(IpcHost._pendingInvokes.size).to.equal(0);
});
it("should reject and clean up the listener/pending-invoke entry when `send` throws synchronously", async () => {
const frontend = mockFrontend();
socket.send.throws(new Error("send failed"));
const promise = IpcHost.invoke("ch");
let caught;
try {
await promise;
expect.fail("invoke should have rejected");
}
catch (err) {
caught = err;
}
expect(caught).to.be.instanceOf(Error);
expect(caught.message).to.equal("send failed");
// the listener and _pendingInvokes entry must not leak when `send` throws synchronously
expect(frontend.listeners.size).to.equal(0);
expect(IpcHost._pendingInvokes.size).to.equal(0);
});
it("should rethrow a frontend-thrown BentleyError from makeIpcProxy preserving ITwinError identity and metadata", async () => {
const frontend = mockFrontend();
const proxy = IpcHost.makeIpcProxy("ch");
const promise = proxy.doIt();
// simulate the frontend handler serializing a thrown BentleyError into the invoke-response envelope
const thrown = new BentleyError(IModelStatus.NotFound, "boom", () => ({ detail: 42 }));
thrown.name = "MyFrontendError";
const serialized = serializeIpcError(thrown, true);
frontend.respond(0, serialized);
let caught;
try {
await promise;
expect.fail("proxy call should have rejected");
}
catch (err) {
caught = err;
}
// Following the ITwinError paradigm, the backend rebuilds a plain Error (no cross-process class identity)
// that callers identify via ITwinError.isError / BentleyError.isError rather than instanceof.
expect(ITwinError.isError(caught, BentleyError.iTwinErrorScope, "MyFrontendError")).to.be.true;
expect(caught.name).to.equal("MyFrontendError");
expect(BentleyError.isError(caught, IModelStatus.NotFound)).to.be.true;
expect(caught.message).to.equal("boom");
expect(caught.loggingMetadata).to.deep.equal({ detail: 42 });
});
it("should not let a non-string thrown `message` field overwrite the 'unknown error' guard in rebuildIpcError", async () => {
const frontend = mockFrontend();
const proxy = IpcHost.makeIpcProxy("ch");
const promise = proxy.doIt();
// simulate a thrown non-Error value with a non-string `message` field (e.g. `throw { message: 123 }`)
const serialized = serializeIpcError({ message: 123 }, true);
frontend.respond(0, serialized);
let caught;
try {
await promise;
expect.fail("proxy call should have rejected");
}
catch (err) {
caught = err;
}
expect(caught).to.be.instanceOf(Error);
expect(caught.message).to.equal("unknown error");
});
it("should rethrow a plain Error from makeIpcProxy when the frontend threw a non-BentleyError", async () => {
const frontend = mockFrontend();
const proxy = IpcHost.makeIpcProxy("ch");
const promise = proxy.doIt();
const serialized = serializeIpcError(new Error("plain failure"), true);
frontend.respond(0, serialized);
let caught;
try {
await promise;
expect.fail("proxy call should have rejected");
}
catch (err) {
caught = err;
}
expect(caught).to.be.instanceOf(Error);
expect(BentleyError.isError(caught)).to.be.false;
expect(caught.message).to.equal("plain failure");
});
});
});
//# sourceMappingURL=IpcHost.test.js.map