Files
oh-my-pi-telegram/tests/sections.test.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

671 lines
20 KiB
TypeScript

/**
* Telegram Extension Sections tests
* Zones: telegram ui, extension platform, callback routing
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
createTelegramExtensionSectionRegistry,
getTelegramExtensionSettingsRows,
getTelegramSectionMainMenuRows,
parseTelegramSectionCallback,
handleTelegramSectionOpen,
handleTelegramSectionCallback,
handleTelegramSectionSettingsOpen,
type TelegramSectionRegistration,
type TelegramSectionCallbackHandlerDeps,
} from "../lib/sections.ts";
function stubSection(
id: string,
label: string,
overrides: Partial<TelegramSectionRegistration> = {},
): TelegramSectionRegistration {
return {
id,
label,
render: async () => ({ text: `<b>${label}</b>`, parseMode: "html" }),
...overrides,
};
}
function stubDeps(
overrides: Partial<TelegramSectionCallbackHandlerDeps> = {},
): TelegramSectionCallbackHandlerDeps {
return {
answerCallbackQuery: async () => {},
editInteractiveMessage: async () => {},
sendInteractiveMessage: async () => undefined,
enqueuePrompt: async () => {},
deleteMessage: async () => {},
...overrides,
};
}
// --- Registry ---
test("Registry registers a section and returns a disposer", () => {
const registry = createTelegramExtensionSectionRegistry();
const unregister = registry.register(stubSection("@test/section-a", "A"));
const sections = registry.getSections();
assert.equal(sections.length, 1);
assert.equal(sections[0].id, "@test/section-a");
assert.equal(sections[0].label, "A");
assert.equal(typeof sections[0].token, "string");
unregister();
assert.equal(registry.getSections().length, 0);
});
test("Registry assigns unique tokens to each section", () => {
const registry = createTelegramExtensionSectionRegistry();
const u1 = registry.register(stubSection("@test/a", "A"));
const u2 = registry.register(stubSection("@test/b", "B"));
const sections = registry.getSections();
assert.equal(sections.length, 2);
assert.notEqual(sections[0].token, sections[1].token);
u1();
u2();
});
test("Registry rejects duplicate section ids", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
assert.throws(
() => registry.register(stubSection("@test/a", "A2")),
/already registered/,
);
});
test("Registry sorts sections by order then id", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/c", "C", { order: 2 }));
registry.register(stubSection("@test/a", "A", { order: 0 }));
registry.register(stubSection("@test/b", "B", { order: 1 }));
const sections = registry.getSections();
assert.equal(sections[0].id, "@test/a");
assert.equal(sections[1].id, "@test/b");
assert.equal(sections[2].id, "@test/c");
});
test("Registry getByToken resolves token to section", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
const sections = registry.getSections();
const found = registry.getByToken(sections[0].token);
assert.ok(found);
assert.equal(found.id, "@test/a");
});
test("Registry getByToken returns undefined for unknown token", () => {
const registry = createTelegramExtensionSectionRegistry();
assert.equal(registry.getByToken("99"), undefined);
});
test("Registry clear removes all sections and resets tokens", () => {
const registry = createTelegramExtensionSectionRegistry();
const u1 = registry.register(stubSection("@test/a", "A"));
registry.clear();
assert.equal(registry.getSections().length, 0);
// New registration gets fresh tokens
const u2 = registry.register(stubSection("@test/b", "B"));
const sections = registry.getSections();
assert.equal(sections[0].token, "0");
u1();
u2();
});
test("Registry diagnostics reports active and error states", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
let diags = registry.getDiagnostics();
assert.equal(diags.length, 1);
assert.equal(diags[0].status, "active");
assert.equal(diags[0].id, "@test/a");
registry.recordError("0", "boom");
diags = registry.getDiagnostics();
assert.equal(diags[0].status, "error");
assert.equal(diags[0].lastError, "boom");
registry.clearError("0");
assert.equal(registry.getDiagnostics()[0].status, "active");
});
// --- Settings Rows ---
test("getTelegramExtensionSettingsRows returns rows only for sections with settings", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
registry.register(
stubSection("@test/b", "B", {
settings: {
label: "🔧 B Settings",
open: async () => ({ text: "B settings" }),
},
}),
);
const rows = getTelegramExtensionSettingsRows(registry);
assert.equal(rows.length, 1);
assert.equal(rows[0].label, "🔧 B Settings");
assert.ok(rows[0].callback_data.startsWith("section:"));
assert.ok(rows[0].callback_data.endsWith(":settings:open"));
});
test("getTelegramExtensionSettingsRows sorts by settings order", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/c", "C", {
settings: { label: "C", open: async () => ({ text: "C" }), order: 2 },
}),
);
registry.register(
stubSection("@test/a", "A", {
settings: { label: "A", open: async () => ({ text: "A" }), order: 0 },
}),
);
const rows = getTelegramExtensionSettingsRows(registry);
assert.equal(rows[0].label, "A");
assert.equal(rows[1].label, "C");
});
// --- Menu Rows ---
test("getTelegramSectionMainMenuRows omits sections with failing labels", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
registry.register(
stubSection("@test/b", "B", {
getLabel: () => {
throw new Error("label failed");
},
}),
);
const rows = getTelegramSectionMainMenuRows(registry);
assert.deepEqual(
rows.map((row) => row.text),
["A"],
);
const diagnostics = registry.getDiagnostics();
assert.equal(diagnostics[1].status, "error");
assert.equal(diagnostics[1].lastError, "label failed");
});
test("getTelegramExtensionSettingsRows omits settings with failing labels", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
settings: { label: "A", open: async () => ({ text: "A" }) },
}),
);
registry.register(
stubSection("@test/b", "B", {
settings: {
label: "B",
getLabel: () => {
throw new Error("settings label failed");
},
open: async () => ({ text: "B" }),
},
}),
);
const rows = getTelegramExtensionSettingsRows(registry);
assert.deepEqual(
rows.map((row) => row.label),
["A"],
);
const diagnostics = registry.getDiagnostics();
assert.equal(diagnostics[1].status, "error");
assert.equal(diagnostics[1].lastError, "settings label failed");
});
test("successful labels do not clear unrelated runtime diagnostics", () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
registry.recordError("0", "callback failed", "callback");
assert.equal(getTelegramSectionMainMenuRows(registry)[0].text, "A");
assert.equal(registry.getDiagnostics()[0].status, "error");
assert.equal(registry.getDiagnostics()[0].lastError, "callback failed");
registry.clearError("0", "callback");
assert.equal(registry.getDiagnostics()[0].status, "active");
});
// --- Callback Parsing ---
test("parseTelegramSectionCallback parses section:token:action", () => {
const result = parseTelegramSectionCallback("section:00:open");
assert.ok(result);
assert.equal(result.token, "00");
assert.equal(result.action, "open");
assert.equal(result.payload, "");
});
test("parseTelegramSectionCallback parses section:token:action:payload", () => {
const result = parseTelegramSectionCallback("section:01:toggle:on");
assert.ok(result);
assert.equal(result.token, "01");
assert.equal(result.action, "toggle");
assert.equal(result.payload, "on");
});
test("parseTelegramSectionCallback parses payload with colons", () => {
const result = parseTelegramSectionCallback("section:05:nav:path:to:item");
assert.ok(result);
assert.equal(result.token, "05");
assert.equal(result.action, "nav");
assert.equal(result.payload, "path:to:item");
});
test("parseTelegramSectionCallback returns undefined for non-section data", () => {
assert.equal(parseTelegramSectionCallback("menu:model"), undefined);
assert.equal(parseTelegramSectionCallback("settings:list"), undefined);
assert.equal(parseTelegramSectionCallback(""), undefined);
});
// --- Section Open ---
test("handleTelegramSectionOpen renders section view with back row", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "🗂 A", {
render: async () => ({
text: "<b>Hello</b>",
parseMode: "html",
replyMarkup: {
inline_keyboard: [
[{ text: "Do", callback_data: "section:0:act:do" }],
],
},
}),
}),
);
let editedText = "";
let editedMarkup: unknown = undefined;
const deps = stubDeps({
editInteractiveMessage: async (
_chatId,
_msgId,
text,
_mode,
replyMarkup,
) => {
editedText = text;
editedMarkup = replyMarkup;
},
});
const handled = await handleTelegramSectionOpen(
registry,
"0",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.equal(editedText, "<b>Hello</b>");
const markup = editedMarkup as {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
};
assert.ok(markup);
// First row is the Back button
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Main menu");
assert.equal(markup.inline_keyboard[0][0].callback_data, "menu:back");
// Second row is the section's own button
assert.equal(markup.inline_keyboard[1][0].text, "Do");
});
test("section callbackData rejects payloads above Telegram's byte limit", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
render: async (ctx) => {
assert.throws(
() => ctx.callbackData("act", "x".repeat(80)),
/64 bytes/,
);
return { text: "ok" };
},
}),
);
const handled = await handleTelegramSectionOpen(
registry,
"0",
123,
456,
"cb-id",
stubDeps(),
);
assert.equal(handled, true);
});
test("handleTelegramSectionOpen preserves existing back button", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
render: async () => ({
text: "Test",
replyMarkup: {
inline_keyboard: [
[{ text: "⬆️ Main menu", callback_data: "menu:back" }],
[{ text: "Custom", callback_data: "section:0:custom" }],
],
},
}),
}),
);
let editedMarkup: unknown = undefined;
const deps = stubDeps({
editInteractiveMessage: async (_a, _b, _c, _d, replyMarkup) => {
editedMarkup = replyMarkup;
},
});
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", deps);
const markup = editedMarkup as {
inline_keyboard: Array<Array<{ text: string }>>;
};
assert.equal(markup.inline_keyboard.length, 2);
// No duplicate back row
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Main menu");
});
test("handleTelegramSectionOpen handles stale token gracefully", async () => {
const registry = createTelegramExtensionSectionRegistry();
let answeredText = "";
const deps = stubDeps({
answerCallbackQuery: async (_id, text) => {
answeredText = text ?? "";
},
});
const handled = await handleTelegramSectionOpen(
registry,
"99",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.ok(answeredText.includes("no longer available"));
});
test("handleTelegramSectionOpen records and clears render errors", async () => {
const registry = createTelegramExtensionSectionRegistry();
let fail = true;
registry.register(
stubSection("@test/a", "A", {
render: async () => {
if (fail) throw new Error("render failed");
return { text: "ok" };
},
}),
);
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", stubDeps());
assert.equal(registry.getDiagnostics()[0].status, "error");
assert.equal(registry.getDiagnostics()[0].lastError, "render failed");
fail = false;
await handleTelegramSectionOpen(registry, "0", 123, 456, "cb-id", stubDeps());
assert.equal(registry.getDiagnostics()[0].status, "active");
});
// --- Section Callback ---
test("handleTelegramSectionCallback dispatches to section handler", async () => {
const registry = createTelegramExtensionSectionRegistry();
let receivedAction = "";
let receivedPayload = "";
registry.register(
stubSection("@test/a", "A", {
handleCallback: async (ctx) => {
receivedAction = ctx.action;
receivedPayload = ctx.payload;
return "handled" as const;
},
}),
);
const deps = stubDeps();
const handled = await handleTelegramSectionCallback(
registry,
"0",
"toggle",
"on",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.equal(receivedAction, "toggle");
assert.equal(receivedPayload, "on");
});
test("handleTelegramSectionCallback falls back to settings handler", async () => {
const registry = createTelegramExtensionSectionRegistry();
let settingsAction = "";
registry.register(
stubSection("@test/a", "A", {
handleCallback: async () => "pass" as const,
settings: {
label: "Settings",
open: async () => ({ text: "settings" }),
handleCallback: async (ctx) => {
settingsAction = ctx.action;
return "handled" as const;
},
},
}),
);
const deps = stubDeps();
await handleTelegramSectionCallback(
registry,
"0",
"toggle",
"off",
123,
456,
"cb-id",
deps,
);
assert.equal(settingsAction, "toggle");
});
test("handleTelegramSectionCallback gives settings-only handlers settings navigation", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
settings: {
label: "Settings",
open: async () => ({ text: "settings" }),
handleCallback: async (ctx) => {
await ctx.edit({ text: "settings detail" });
return "handled" as const;
},
},
}),
);
let editedMarkup: unknown;
const deps = stubDeps({
editInteractiveMessage: async (_chat, _message, _text, _mode, markup) => {
editedMarkup = markup;
},
});
await handleTelegramSectionCallback(
registry,
"0",
"toggle",
"off",
123,
456,
"cb-id",
deps,
);
const markup = editedMarkup as {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
};
assert.equal(markup.inline_keyboard[0][0].text, "⬆️ Back");
assert.equal(markup.inline_keyboard[0][0].callback_data, "settings:list");
});
test("handleTelegramSectionCallback answers when no handler exists", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
let answered = false;
const deps = stubDeps({
answerCallbackQuery: async () => {
answered = true;
},
});
const handled = await handleTelegramSectionCallback(
registry,
"0",
"act",
"",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.equal(answered, true);
});
test("handleTelegramSectionCallback handles stale token", async () => {
const registry = createTelegramExtensionSectionRegistry();
let answeredText = "";
const deps = stubDeps({
answerCallbackQuery: async (_id, text) => {
answeredText = text ?? "";
},
});
const handled = await handleTelegramSectionCallback(
registry,
"99",
"act",
"",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.ok(answeredText.includes("no longer available"));
});
// --- Section Settings Open ---
test("handleTelegramSectionSettingsOpen renders settings view", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
settings: {
label: "⚙️ A Settings",
open: async () => ({
text: "<b>Settings for A</b>",
parseMode: "html",
}),
},
}),
);
let editedText = "";
const deps = stubDeps({
editInteractiveMessage: async (_a, _b, text) => {
editedText = text;
},
});
const handled = await handleTelegramSectionSettingsOpen(
registry,
"0",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.equal(editedText, "<b>Settings for A</b>");
});
test("handleTelegramSectionSettingsOpen handles section without settings gracefully", async () => {
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "A"));
let answeredText = "";
const deps = stubDeps({
answerCallbackQuery: async (_id, text) => {
answeredText = text ?? "";
},
});
const handled = await handleTelegramSectionSettingsOpen(
registry,
"0",
123,
456,
"cb-id",
deps,
);
assert.equal(handled, true);
assert.ok(answeredText.includes("no longer available"));
});
// --- Integration: menu-status rows ---
test("buildStatusReplyMarkup includes extension section rows before Settings", async () => {
// Import dynamically to avoid circular deps in test
const { buildStatusReplyMarkup } = await import("../lib/menu-status.ts");
const registry = createTelegramExtensionSectionRegistry();
registry.register(stubSection("@test/a", "Explorer"));
registry.register(
stubSection("@test/b", "Status", {
getLabel: () => "🟢 Status",
}),
);
const markup = buildStatusReplyMarkup(undefined, "off" as never, 0, registry);
const rows = markup.inline_keyboard;
// Find the Settings row index
const settingsIdx = rows.findIndex(
(r) => r[0].callback_data === "menu:settings",
);
assert.ok(settingsIdx > 0);
// Queue should be before section rows
assert.equal(rows[settingsIdx - 3][0].text, "⌛ Queue: 0");
// Section rows injected between Queue and Settings
assert.equal(rows[settingsIdx - 2][0].text, "Explorer");
assert.equal(rows[settingsIdx - 2][0].callback_data, "section:0:open");
assert.equal(rows[settingsIdx - 1][0].text, "🟢 Status");
assert.equal(rows[settingsIdx - 1][0].callback_data, "section:1:open");
});
// --- Integration: menu-settings rows ---
test("buildTelegramSettingsMenuReplyMarkup injects extension settings rows", async () => {
const { buildTelegramSettingsMenuReplyMarkup } =
await import("../lib/menu-settings.ts");
const registry = createTelegramExtensionSectionRegistry();
registry.register(
stubSection("@test/a", "A", {
settings: {
label: "🔧 Extension A",
open: async () => ({ text: "A settings" }),
},
}),
);
const markup = buildTelegramSettingsMenuReplyMarkup(
false,
false,
"manual",
"hidden",
registry,
);
const rows = markup.inline_keyboard;
// First row: Main menu back
assert.equal(rows[0][0].callback_data, "menu:back");
// Second row: extension settings
assert.equal(rows[1][0].text, "🔧 Extension A");
assert.ok(rows[1][0].callback_data.startsWith("section:"));
// Built-in rows follow extension settings
assert.ok(rows[2][0].text.includes("Voice reply"));
assert.ok(rows[3][0].text.includes("Time"));
assert.ok(rows[4][0].text.includes("Draft previews"));
assert.ok(rows[5][0].text.includes("Rendering"));
assert.ok(rows[6][0].text.includes("Proactive push"));
});