Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This project is a TypeScript Telegram bot. The codebase uses Node.js tooling wit
- Run `npm run test-full` before commit.
- Run `npm run format` before commit.

## Coverage improve rules
- Run `npm test` and `npm run coverage-info` to check coverage, sorted by lines_uncovered.
- Prefer less covered files.
- Cover each function first.
- Check `npm run coverage-info` in the end of each iteration, calculate coverage change.

## Project Structure

- **src/** – main source code (`bot.ts`, `config.ts`, helpers, tools, etc.)
Expand Down
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const config = {
setupFilesAfterEnv: ['<rootDir>/tests/setupTests.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: [
'json-summary',
'lcov',
'text',
'text-summary'
],
};

export default config;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"test": "node --no-warnings=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:coverage": "npm test -- --coverage",
"test-full": "npm run test && npm run typecheck && npm run lint src tests",
"coverage-info": "tsx src/utils/coverage-info.ts",
"prepare-commit": "npm run test-full && npm run format",
"typecheck": "tsc --noEmit",
"lint": "eslint",
Expand Down
15 changes: 14 additions & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import { log } from "./helpers.ts";
// Store active MCP clients by model key
const clients: Record<string, Client> = {};

export const __test = {
/** Reset cached clients - used in tests */
resetClients() {
for (const key of Object.keys(clients)) {
delete clients[key];
}
},
/** Manually set a client for a model - used in tests */
setClient(model: string, client: Client) {
clients[model] = client;
},
};

type McpTool = {
name: string;
description: string;
Expand Down Expand Up @@ -137,7 +150,7 @@ function initSseMcp(serverUrl: string, model: string, client: Client) {
/**
* Connect to an MCP server with the given configuration
*/
async function connectMcp(
export async function connectMcp(
model: string,
cfg: McpToolConfig,
clients: Record<string, Client>,
Expand Down
86 changes: 86 additions & 0 deletions src/utils/coverage-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { readFileSync, existsSync } from 'fs';
import path from 'path';

interface CoverageMetrics {
total: number;
covered: number;
skipped: number;
pct: number;
}

interface FileCoverage {
lines: CoverageMetrics;
statements: CoverageMetrics;
functions: CoverageMetrics;
branches: CoverageMetrics;
}

interface CoverageSummary {
total: FileCoverage;
[filePath: string]: FileCoverage;
}

export interface FileCoverageInfo {
path: string;
lines_total: number;
lines_covered: number;
lines_uncovered: number;
lines_coverage: number;
functions_total: number;
functions_covered: number;
functions_uncovered: number;
functions_coverage: number;
}

function toRelativePath(filePath: string): string {
return filePath.replace(process.cwd(), '').replace(/\\/g, '/');
}

export function parseCoverage(coveragePath = 'coverage/coverage-summary.json'): FileCoverageInfo[] {
const absolutePath = path.resolve(process.cwd(), coveragePath);

if (!existsSync(absolutePath)) {
return [];
}

try {
const coverageData: CoverageSummary = JSON.parse(
readFileSync(absolutePath, 'utf-8')
);

return Object.entries(coverageData)
.filter(([key]) => key !== 'total')
.map(([filePath, fileCoverage]) => ({
path: toRelativePath(filePath),
lines_total: fileCoverage.lines.total,
lines_covered: fileCoverage.lines.covered,
lines_uncovered: fileCoverage.lines.total - fileCoverage.lines.covered,
lines_coverage: fileCoverage.lines.pct,
functions_total: fileCoverage.functions.total,
functions_covered: fileCoverage.functions.covered,
functions_uncovered: fileCoverage.functions.total - fileCoverage.functions.covered,
functions_coverage: fileCoverage.functions.pct,
}))
.sort((a, b) => b.lines_uncovered - a.lines_uncovered);
} catch (error) {
console.error('Error parsing coverage file:', error);
return [];
}
}

export function coverageInfo(coveragePath = 'coverage/coverage-summary.json'): void {
const coverage = parseCoverage(coveragePath);
console.log(JSON.stringify(coverage, null, 2));
}

// Convert file path to URL format for comparison
function toFileUrl(filePath: string): string {
const pathName = path.resolve(filePath).replace(/\\/g, '/');
return `file://${pathName.startsWith('/') ? '' : '/'}${pathName}`;
}

// Run if this file is executed directly
const currentFileUrl = toFileUrl(process.argv[1]);
if (import.meta.url === currentFileUrl) {
coverageInfo();
}
49 changes: 49 additions & 0 deletions tests/bot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { jest, describe, it, beforeEach, expect } from "@jest/globals";

const mockStop = jest.fn();
const TelegrafMock = jest.fn().mockImplementation(() => ({ stop: mockStop }));
const mockUseConfig = jest.fn();

jest.unstable_mockModule("../src/config.ts", () => ({
useConfig: () => mockUseConfig(),
}));

jest.unstable_mockModule("telegraf", () => ({
Telegraf: TelegrafMock,
}));

let useBot: typeof import("../src/bot.ts").useBot;

beforeEach(async () => {
jest.resetModules();
jest.clearAllMocks();
({ useBot } = await import("../src/bot.ts"));
});

describe("useBot", () => {
it("creates bot with config token and caches instance", () => {
mockUseConfig.mockReturnValue({ auth: { bot_token: "token1" } });
const onceSpy = jest
.spyOn(process, "once")
.mockImplementation(() => process);

const first = useBot();
const second = useBot();

expect(first).toBe(second);
expect(TelegrafMock).toHaveBeenCalledTimes(1);
expect(TelegrafMock).toHaveBeenCalledWith("token1");
expect(onceSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
expect(onceSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
onceSpy.mockRestore();
});

it("uses provided token when passed", () => {
mockUseConfig.mockReturnValue({ auth: { bot_token: "token1" } });
const bot = useBot("custom");
const again = useBot("custom");
expect(bot).toBe(again);
expect(TelegrafMock).toHaveBeenCalledTimes(1);
expect(TelegrafMock).toHaveBeenCalledWith("custom");
});
});
52 changes: 52 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { jest, describe, it, beforeEach, expect } from "@jest/globals";

let callMcp: typeof import("../src/mcp.ts").callMcp;
let __test: typeof import("../src/mcp.ts").__test;

beforeEach(async () => {
jest.resetModules();
({ callMcp, __test } = await import("../src/mcp.ts"));
__test.resetClients();
});

describe("callMcp", () => {
it("returns message when client missing", async () => {
const res = await callMcp("model", "tool", "{}");
expect(res).toEqual({ content: "MCP client not initialized: model" });
});

it("calls tool and returns string result", async () => {
const client = {
callTool: jest.fn().mockResolvedValue({ content: "ok" }),
} as unknown as {
callTool: jest.Mock<Promise<{ content: string }>, [unknown]>;
};
__test.setClient("m1", client);
const res = await callMcp("m1", "foo", "{}");
expect(client.callTool).toHaveBeenCalledWith({
name: "foo",
arguments: {},
});
expect(res).toEqual({ content: "ok" });
});

it("stringifies non string result", async () => {
const client = {
callTool: jest.fn().mockResolvedValue({ content: { a: 1 } }),
} as unknown as {
callTool: jest.Mock<Promise<{ content: unknown }>, [unknown]>;
};
__test.setClient("m2", client);
const res = await callMcp("m2", "foo", "{}");
expect(res).toEqual({ content: JSON.stringify({ a: 1 }) });
});

it("handles errors", async () => {
const client = {
callTool: jest.fn().mockRejectedValue(new Error("bad")),
} as unknown as { callTool: jest.Mock<Promise<never>, [unknown]> };
__test.setClient("m3", client);
const res = await callMcp("m3", "foo", "{}");
expect(res).toEqual({ content: "MCP call error: bad" });
});
});
93 changes: 93 additions & 0 deletions tests/mqtt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { jest, describe, it, beforeEach, expect } from "@jest/globals";

const handlers: Record<string, (...args: unknown[]) => unknown> = {};
const mqttClient = {
on: (event: string, fn: (...args: unknown[]) => unknown) => {
handlers[event] = fn;
},
subscribe: jest.fn(),
publish: jest.fn(),
};

const mockConnect = jest.fn(() => mqttClient);
const mockUseConfig = jest.fn();
const mockRunAgent = jest.fn();
const mockLog = jest.fn();

jest.unstable_mockModule("mqtt", () => ({
default: { connect: (...args: unknown[]) => mockConnect(...args) },
}));

jest.unstable_mockModule("../src/config.ts", () => ({
useConfig: () => mockUseConfig(),
}));

jest.unstable_mockModule("../src/agent-runner.ts", () => ({
runAgent: (...args: unknown[]) => mockRunAgent(...args),
}));

jest.unstable_mockModule("../src/helpers.ts", () => ({
log: (...args: unknown[]) => mockLog(...args),
}));

let useMqtt: typeof import("../src/mqtt.ts").useMqtt;
let publishMqttProgress: typeof import("../src/mqtt.ts").publishMqttProgress;

beforeEach(async () => {
jest.resetModules();
jest.clearAllMocks();
for (const key of Object.keys(handlers)) delete handlers[key];
({ useMqtt, publishMqttProgress } = await import("../src/mqtt.ts"));
});

describe("useMqtt", () => {
it("returns undefined when config missing", () => {
mockUseConfig.mockReturnValue({});
expect(useMqtt()).toBeUndefined();
});

it("connects and handles events", async () => {
mockUseConfig.mockReturnValue({ mqtt: { base: "base" } });
const client1 = useMqtt();
const client2 = useMqtt();
expect(client1).toBe(client2);
expect(mockConnect).toHaveBeenCalledTimes(1);

handlers.connect();
expect(mockLog).toHaveBeenCalledWith({
msg: "mqtt connected",
logPath: "data/mqtt.log",
});
expect(mqttClient.subscribe).toHaveBeenCalledWith("base/+");

handlers.offline();
expect(mockLog).toHaveBeenCalledWith({
msg: "mqtt offline",
logPath: "data/mqtt.log",
});

mockRunAgent.mockResolvedValue("answer");
await handlers.message("base/agent", Buffer.from("hi"));
expect(mockRunAgent).toHaveBeenCalledWith(
"agent",
"hi",
expect.any(Function),
);
expect(mqttClient.publish).toHaveBeenCalledWith(
"base/agent/answer",
"answer",
);
});
});

describe("publishMqttProgress", () => {
it("publishes progress when agent and config present", () => {
mockUseConfig.mockReturnValue({ mqtt: { base: "base" } });
useMqtt();
publishMqttProgress("step", "agent");
expect(mqttClient.publish).toHaveBeenCalledWith(
"base/agent/progress",
"step",
);
});
});