@moonwall/cli
Version:
Testing framework for the Moon family of projects
300 lines (295 loc) • 11 kB
JavaScript
// src/internal/effect/__tests__/NodeReadinessService.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { Effect as Effect2, Exit, Layer as Layer2 } from "effect";
import { Socket as Socket2 } from "@effect/platform";
// src/internal/effect/NodeReadinessService.ts
import { Socket } from "@effect/platform";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import { createLogger } from "@moonwall/util";
import { Context, Deferred, Effect, Fiber, Layer, Schedule } from "effect";
// src/internal/effect/errors.ts
import { Data } from "effect";
var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") {
};
var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") {
};
var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") {
};
var ProcessError = class extends Data.TaggedError("ProcessError") {
};
var StartupCacheError = class extends Data.TaggedError("StartupCacheError") {
};
var FileLockError = class extends Data.TaggedError("FileLockError") {
};
// src/internal/effect/NodeReadinessService.ts
var logger = createLogger({ name: "NodeReadinessService" });
var debug = logger.debug.bind(logger);
var NodeReadinessService = class extends Context.Tag("NodeReadinessService")() {
};
var sendRpcRequest = (method) => Effect.flatMap(
Deferred.make(),
(responseDeferred) => Effect.flatMap(
Socket.Socket,
(socket) => Effect.flatMap(socket.writer, (writer) => {
debug(`Checking method: ${method}`);
const request = new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 1e4),
method,
params: []
})
);
const handleMessages = socket.runRaw((data) => {
try {
const message = typeof data === "string" ? data : new TextDecoder().decode(data);
debug(`Got message for ${method}: ${message.substring(0, 100)}`);
const response = JSON.parse(message);
debug(`Parsed response: jsonrpc=${response.jsonrpc}, error=${response.error}`);
if (response.jsonrpc === "2.0" && !response.error) {
debug(`Method ${method} succeeded!`);
return Deferred.succeed(responseDeferred, true);
}
} catch (e) {
debug(`Parse error for ${method}: ${e}`);
}
return Effect.void;
});
return Effect.flatMap(
Effect.fork(handleMessages),
(messageFiber) => Effect.flatMap(
writer(request),
() => (
// Wait for either:
// 1. Deferred resolving with response (success)
// 2. Timeout (fails with error)
// Also check if the message fiber has failed
Deferred.await(responseDeferred).pipe(
Effect.timeoutFail({
duration: "2 seconds",
onTimeout: () => new Error("RPC request timed out waiting for response")
}),
// After getting result, check if fiber failed and prefer that error
Effect.flatMap(
(result) => Fiber.poll(messageFiber).pipe(
Effect.flatMap((pollResult) => {
if (pollResult._tag === "Some") {
const exit = pollResult.value;
if (exit._tag === "Failure") {
return Effect.failCause(exit.cause);
}
}
return Effect.succeed(result);
})
)
)
)
)
).pipe(
Effect.ensuring(Fiber.interrupt(messageFiber)),
Effect.catchAll(
(cause) => Effect.fail(
new NodeReadinessError({
cause,
port: 0,
// Will be filled in by caller
attemptsExhausted: 1
})
)
)
)
);
})
)
);
var attemptReadinessCheck = (config) => Effect.logDebug(
`Attempting readiness check on port ${config.port}, isEthereum: ${config.isEthereumChain}`
).pipe(
Effect.flatMap(
() => Effect.scoped(
Effect.flatMap(sendRpcRequest("system_chain"), (systemChainOk) => {
if (systemChainOk) {
return Effect.succeed(true);
}
if (config.isEthereumChain) {
return sendRpcRequest("eth_chainId");
}
return Effect.succeed(false);
})
).pipe(
Effect.timeoutFail({
duration: "3 seconds",
onTimeout: () => new NodeReadinessError({
cause: new Error("Readiness check timed out"),
port: config.port,
attemptsExhausted: 1
})
}),
Effect.catchAll(
(cause) => Effect.fail(
new NodeReadinessError({
cause,
port: config.port,
attemptsExhausted: 1
})
)
)
)
)
);
var checkReadyWithRetryInternal = (config) => {
const maxAttempts = config.maxAttempts || 200;
return attemptReadinessCheck(config).pipe(
Effect.retry(
Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
),
Effect.catchAll(
(error) => Effect.fail(
new NodeReadinessError({
cause: error,
port: config.port,
attemptsExhausted: maxAttempts
})
)
)
);
};
var checkReadyWithRetry = (config) => {
return checkReadyWithRetryInternal(config).pipe(
Effect.provide(NodeSocket.layerWebSocket(`ws://localhost:${config.port}`))
);
};
var NodeReadinessServiceLive = Layer.succeed(NodeReadinessService, {
checkReady: checkReadyWithRetry
});
var makeNodeReadinessServiceTest = (socketLayer) => Layer.succeed(NodeReadinessService, {
checkReady: (config) => checkReadyWithRetryInternal(config).pipe(Effect.provide(socketLayer))
});
// src/internal/effect/__tests__/NodeReadinessService.test.ts
var createMockSocket = (config) => {
const mockSocket = {
[Socket2.TypeId]: Socket2.TypeId,
run: (handler) => {
if (config.shouldError) {
return Effect2.fail(
new Socket2.SocketGenericError({ reason: "Open", cause: new Error("Connection refused") })
);
}
if (config.shouldSucceed && config.responseData) {
const data = new TextEncoder().encode(JSON.stringify(config.responseData));
return Effect2.flatMap(
Effect2.sleep("10 millis"),
() => Effect2.sync(() => {
handler(data);
})
);
}
return Effect2.never;
},
runRaw: (handler, options) => {
if (config.shouldError) {
return Effect2.fail(
new Socket2.SocketGenericError({ reason: "Open", cause: new Error("Connection refused") })
);
}
const handleOpenAndData = () => {
if (config.shouldSucceed && config.responseData) {
return Effect2.flatMap(Effect2.sleep("10 millis"), () => {
const data = new TextEncoder().encode(JSON.stringify(config.responseData));
const handlerResult = handler(data);
const handlerEffect = Effect2.isEffect(handlerResult) ? handlerResult : Effect2.void;
return Effect2.flatMap(handlerEffect, () => Effect2.never);
});
}
return Effect2.never;
};
if (options?.onOpen) {
return Effect2.flatMap(options.onOpen, () => handleOpenAndData());
}
return handleOpenAndData();
},
writer: Effect2.succeed((_chunk) => Effect2.void)
};
return mockSocket;
};
var createMockSocketLayer = (config) => {
return Layer2.succeed(Socket2.Socket, createMockSocket(config));
};
describe("NodeReadinessService", () => {
beforeEach(() => {
});
it("should successfully check readiness when system_chain responds", async () => {
const mockConfig = { port: 9999, isEthereumChain: false, maxAttempts: 1 };
const mockSocketLayer = createMockSocketLayer({
shouldSucceed: true,
responseData: { jsonrpc: "2.0", id: 1, result: "Moonbeam" }
});
const program = NodeReadinessService.pipe(
Effect2.flatMap((service) => service.checkReady(mockConfig)),
Effect2.provide(makeNodeReadinessServiceTest(mockSocketLayer))
);
const exit = await Effect2.runPromiseExit(program);
expect(Exit.isSuccess(exit)).toBe(true);
if (Exit.isSuccess(exit)) {
expect(exit.value).toBe(true);
}
});
it("should fail if WebSocket connection errors", async () => {
const mockConfig = { port: 1234, isEthereumChain: false, maxAttempts: 1 };
const mockSocketLayer = createMockSocketLayer({
shouldSucceed: false,
shouldError: true
});
const program = NodeReadinessService.pipe(
Effect2.flatMap((service) => service.checkReady(mockConfig)),
Effect2.provide(makeNodeReadinessServiceTest(mockSocketLayer))
);
const exit = await Effect2.runPromiseExit(program);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(exit.cause._tag).toBe("Fail");
if (exit.cause._tag === "Fail") {
expect(exit.cause.error).toBeInstanceOf(NodeReadinessError);
expect(exit.cause.error.port).toBe(mockConfig.port);
}
}
});
it("should check system_chain for non-Ethereum chains", async () => {
const mockConfig = { port: 9999, isEthereumChain: false, maxAttempts: 1 };
const mockSocketLayer = createMockSocketLayer({
shouldSucceed: true,
responseData: { jsonrpc: "2.0", id: 1, result: "Polkadot" }
});
const program = NodeReadinessService.pipe(
Effect2.flatMap((service) => service.checkReady(mockConfig)),
Effect2.provide(makeNodeReadinessServiceTest(mockSocketLayer))
);
const exit = await Effect2.runPromiseExit(program);
expect(Exit.isSuccess(exit)).toBe(true);
});
it("should check both system_chain and eth_chainId for Ethereum chains", async () => {
const mockConfig = { port: 9999, isEthereumChain: true, maxAttempts: 1 };
const mockSocketLayer = createMockSocketLayer({
shouldSucceed: true,
responseData: { jsonrpc: "2.0", id: 1, result: "0x507" }
});
const program = NodeReadinessService.pipe(
Effect2.flatMap((service) => service.checkReady(mockConfig)),
Effect2.provide(makeNodeReadinessServiceTest(mockSocketLayer))
);
const exit = await Effect2.runPromiseExit(program);
expect(Exit.isSuccess(exit)).toBe(true);
});
it("should timeout if no response within configured time", async () => {
const mockConfig = { port: 9999, isEthereumChain: false, maxAttempts: 1 };
const mockSocketLayer = createMockSocketLayer({
shouldSucceed: false
});
const program = NodeReadinessService.pipe(
Effect2.flatMap((service) => service.checkReady(mockConfig)),
Effect2.provide(makeNodeReadinessServiceTest(mockSocketLayer))
);
const exit = await Effect2.runPromiseExit(program);
expect(Exit.isFailure(exit)).toBe(true);
});
});