mirror of
https://github.com/minescope/mineping.git
synced 2025-07-01 14:18:14 +03:00
Add support for DNS SRV record lookups (`_minecraft._tcp`) to automatically resolve the correct server host and port, falling back to the provided address if no record is found. This makes server discovery compliant with standard Minecraft client behavior. The Java ping implementation has been refactored: - The `varint` module is rewritten to throw specific error codes and its `decodeVarInt` function now returns bytes read, which simplifies parsing logic. - The core ping logic is now promise-based and modular, breaking out packet creation and response processing into helper functions. - The TCP stream handler now robustly processes chunked data by catching recoverable decoder errors and waiting for more data, preventing crashes on incomplete packets. - Error handling is improved.
134 lines
4.1 KiB
JavaScript
134 lines
4.1 KiB
JavaScript
import net from "node:net";
|
|
import dns from "node:dns/promises";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { pingJava } from "../lib/java.js";
|
|
import * as varint from "../lib/varint.js";
|
|
|
|
vi.mock("node:net");
|
|
vi.mock("node:dns/promises");
|
|
|
|
describe("pingJava", () => {
|
|
let mockSocket;
|
|
|
|
beforeEach(() => {
|
|
// Simulate no SRV record found.
|
|
dns.resolveSrv.mockResolvedValue([]);
|
|
|
|
const mockHandlers = {};
|
|
mockSocket = {
|
|
write: vi.fn(),
|
|
// Make `destroy` emit 'error' if an error is passed.
|
|
destroy: vi.fn((err) => {
|
|
if (err) {
|
|
mockSocket.emit("error", err);
|
|
}
|
|
}),
|
|
setNoDelay: vi.fn(),
|
|
on: vi.fn((event, handler) => (mockHandlers[event] = handler)),
|
|
emit: vi.fn((event, ...args) => mockHandlers[event]?.(...args)),
|
|
};
|
|
net.createConnection.mockReturnValue(mockSocket);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("should ping a server and handle a chunked response", async () => {
|
|
const host = "mc.hypixel.net";
|
|
const options = { port: 25565 };
|
|
const pingPromise = pingJava(host, options);
|
|
|
|
// Allow the async SRV lookup to complete
|
|
await vi.runAllTicks();
|
|
|
|
expect(dns.resolveSrv).toHaveBeenCalledWith(`_minecraft._tcp.${host}`);
|
|
expect(net.createConnection).toHaveBeenCalledWith({
|
|
host,
|
|
port: options.port,
|
|
});
|
|
|
|
mockSocket.emit("connect");
|
|
expect(mockSocket.write).toHaveBeenCalledTimes(2);
|
|
|
|
const mockResponse = {
|
|
version: { name: "1.21", protocol: 765 },
|
|
players: { max: 20, online: 5, sample: [] },
|
|
description: "A Minecraft Server",
|
|
};
|
|
|
|
const fullPacket = createMockJavaResponse(mockResponse);
|
|
const chunk1 = fullPacket.subarray(0, 10);
|
|
const chunk2 = fullPacket.subarray(10);
|
|
|
|
// Simulate receiving data in chunks
|
|
mockSocket.emit("data", chunk1);
|
|
mockSocket.emit("data", chunk2);
|
|
|
|
const result = await pingPromise;
|
|
expect(result).toEqual(mockResponse);
|
|
});
|
|
|
|
describe("errors", () => {
|
|
it("should throw an error if host is not provided", async () => {
|
|
await expect(pingJava(null)).rejects.toThrow("Host argument is required");
|
|
});
|
|
|
|
it("should reject on socket timeout", async () => {
|
|
const pingPromise = pingJava("localhost", { timeout: 1000 });
|
|
await vi.runAllTicks();
|
|
mockSocket.emit("connect");
|
|
vi.advanceTimersByTime(1000);
|
|
await expect(pingPromise).rejects.toThrow("Socket timeout");
|
|
});
|
|
|
|
it("should reject on connection error", async () => {
|
|
const pingPromise = pingJava("localhost");
|
|
await vi.runAllTicks();
|
|
mockSocket.emit("error", new Error("ECONNREFUSED"));
|
|
await expect(pingPromise).rejects.toThrow("ECONNREFUSED");
|
|
});
|
|
|
|
it("should reject if the socket closes prematurely without a response", async () => {
|
|
const pingPromise = pingJava("localhost");
|
|
|
|
// Allow the initial async operations to complete
|
|
await vi.runAllTicks();
|
|
|
|
// Simulate the server accepting the connection and then immediately closing it
|
|
mockSocket.emit("connect");
|
|
mockSocket.emit("close");
|
|
|
|
// The promise should reject with our specific 'close' handler message
|
|
await expect(pingPromise).rejects.toThrow(
|
|
"Socket closed unexpectedly without a response."
|
|
);
|
|
});
|
|
|
|
it("should only reject once, even if multiple errors occur", async () => {
|
|
const pingPromise = pingJava("localhost");
|
|
await vi.runAllTicks();
|
|
mockSocket.emit("error", new Error("First error"));
|
|
mockSocket.emit("error", new Error("Second error")); // Should be ignored
|
|
await expect(pingPromise).rejects.toThrow("First error");
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Creates a mock Java status response packet according to the protocol.
|
|
* Structure: [Overall Length] [Packet ID] [JSON Length] [JSON String]
|
|
* @param {object} response The JSON response object
|
|
* @returns {Buffer}
|
|
*/
|
|
function createMockJavaResponse(response) {
|
|
const jsonString = JSON.stringify(response);
|
|
const jsonBuffer = varint.encodeString(jsonString);
|
|
const jsonLength = varint.encodeVarInt(jsonBuffer.length);
|
|
const packetId = varint.encodeVarInt(0x00);
|
|
const payloadParts = [packetId, jsonLength, jsonBuffer];
|
|
return varint.concatPackets(payloadParts);
|
|
}
|