UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

300 lines (295 loc) 11 kB
// 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); }); });