Files
roboco/panel/src/app/(tg)/tg/__tests__/page.test.tsx
T
3c5ee46347 feat(tg): Mini App V6 — premium overhaul (#609)
* feat(tg): Mini App V6 — premium overhaul (design system, Chat parity, Metrics drilldown, CEO verbs)

Design system: native type with tabular-numeral heroes (mono demoted to
the wordmark), borderless elevated cards, floating dock, Telegram
window-chrome painting via the theme bridge; Inbox moves behind a header
bell with humanized notifications (UUIDs resolve to task names).

Chat: honest Mine/Fleet split — participant-scoped CEO threads with real
unread counts and mark-read, watched fleet threads with reply-as-CEO on
task-linked conversations (watch-only otherwise), markdown transcripts,
live pulse flashes, and a pinned Secretary live chat on the panel's SSE
session runtime.

Metrics: new tab with period-segmented spend hero, by-agent/team/model
breakdowns, delivery + efficiency health, and a per-agent drilldown over
usage time-series (agent_slug) + member scorecard.

Board: tg-native grouped pipeline replacing the MobileTaskBoard wrapper;
task sheet gains the CEO decide verbs (approve / request changes /
unblock).

Security: /api/dashboard router now require_panel_token-gated at router
level (mirrors /api/usage), closing unauthenticated metrics exposure.

* fix(tg): restore Share Tech Mono brand voice, Phosphor icon set, borderless avatars

The mono returns as the numeral/brand voice (.tg-display — heroes, stat
values, wordmark) while labels stay native sentence case. The hand-drawn
duotone glyphs and lucide feature icons are replaced by Phosphor (MIT):
duotone at rest via an IconContext at the shell, filled weight on the
dock's active tab; row glyph maps (board statuses, inbox kinds, approval
kinds, quick actions) all move over. Team avatar tiles drop their borders
— tint-only squircles.

* fix(tg): fleet avatar strip breathes — spaced tiles instead of overlap

* polish(tg): taste-skill audit pass — em-dash purge, one icon family, separator rationing

Applied the design-taste audit against the cockpit: every em-dash in
visible UI copy rewritten (periods/commas/colons), the remaining lucide
chrome (carets, arrows, send, close, spinners) moved to Phosphor so the
tg tree ships one icon family (send is the native paper-plane, carets
bold), the hand-rolled chevron SVG deleted, and metadata lines rationed
to a single middle-dot separator.

* polish(tg): pipeline chip strip scrolls without a visible scrollbar

* fix(tests): metrics observability fixture uses a relative timestamp

The hardcoded _T0 (2026-06-20) aged out of the service's 30-day window
exactly 30 days later, detonating the suite on every branch. Two days
back from now() stays inside every window (30d metrics, 7d scorecards)
permanently.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 17:29:22 +02:00

187 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const { waitForTelegramWebApp } = vi.hoisted(() => ({
waitForTelegramWebApp: vi.fn(),
}));
// Keep the real dev-mock helpers (createDevMockWebApp / isDevMockWebApp) —
// only the bridge resolver is faked.
vi.mock("@/lib/telegram/webapp", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
waitForTelegramWebApp,
}));
const { startTelegramThemeSync } = vi.hoisted(() => ({
startTelegramThemeSync: vi.fn(() => () => undefined),
}));
vi.mock("@/lib/telegram/theme", () => ({ startTelegramThemeSync }));
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@/lib/api/client", () => ({
default: { post },
getErrorMessage: (err: unknown) =>
(err as { message?: string } | undefined)?.message ?? "Unknown error",
}));
// The cockpit tabs each fetch their own data (queue cards, tasks,
// notifications, A2A) — stubbed out here since this test only exercises the
// bootstrap state machine, not tab content (each tab gets its own coverage).
vi.mock("@/components/tg/tg-tab-bar", () => ({
TgTabBar: () => <div data-testid="tg-tab-bar" />,
}));
vi.mock("@/components/tg/tg-today-tab", () => ({
TgTodayTab: () => <div data-testid="tg-today-tab" />,
}));
vi.mock("@/components/tg/tg-approvals-tab", () => ({
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
}));
vi.mock("@/components/tg/tg-inbox-tab", () => ({
TgInboxTab: () => <div data-testid="tg-inbox-tab" />,
}));
vi.mock("@/components/tg/tg-board-tab", () => ({
TgBoardTab: () => <div data-testid="tg-board-tab" />,
}));
vi.mock("@/components/tg/tg-chat-tab", () => ({
TgChatTab: () => <div data-testid="tg-chat-tab" />,
}));
vi.mock("@/components/tg/tg-metrics-tab", () => ({
TgMetricsTab: () => <div data-testid="tg-metrics-tab" />,
}));
// The shell's bell badge count — stubbed so the bootstrap test needs no
// QueryClientProvider.
vi.mock("@/hooks/use-notifications", () => ({
useNotifications: () => ({ data: undefined }),
}));
import TelegramMiniAppPage from "../page";
function mockWebApp(initData = "abc123") {
return {
ready: vi.fn(),
expand: vi.fn(),
disableVerticalSwipes: vi.fn(),
initData,
};
}
describe("TelegramMiniAppPage — auth bootstrap", () => {
beforeEach(() => {
waitForTelegramWebApp.mockReset();
startTelegramThemeSync.mockClear();
post.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("shows a spinner while validating", () => {
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
render(<TelegramMiniAppPage />);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
it("renders the not-inside-Telegram screen when no WebApp object exists", async () => {
waitForTelegramWebApp.mockResolvedValue(null);
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
});
it("shows the Open-from-Telegram wall in production when the CDN bridge has empty initData", async () => {
// A plain browser at /tg: the telegram.org script defines WebApp but
// with no initData. Production must not post the empty payload (422) —
// it shows the wall, same as no bridge at all.
waitForTelegramWebApp.mockResolvedValue(mockWebApp(""));
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
});
it("calls ready/expand, posts initData, and renders the cockpit on success", async () => {
const webApp = mockWebApp("real-init-data");
waitForTelegramWebApp.mockResolvedValue(webApp);
post.mockResolvedValue({ data: { ok: true } });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(webApp.ready).toHaveBeenCalledTimes(1);
expect(webApp.expand).toHaveBeenCalledTimes(1);
expect(webApp.disableVerticalSwipes).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
init_data: "real-init-data",
});
// Default tab is Today.
expect(screen.getByTestId("tg-today-tab")).toBeInTheDocument();
});
it("renders an error screen with the server's message when auth is refused", async () => {
waitForTelegramWebApp.mockResolvedValue(mockWebApp());
post.mockRejectedValue({ message: "Mini App disabled" });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/couldn.t sign in/i)).toBeInTheDocument(),
);
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument();
});
it("falls back to the dev mock outside Telegram in development — no auth POST", async () => {
vi.stubEnv("NODE_ENV", "development");
waitForTelegramWebApp.mockResolvedValue(null);
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
expect(screen.queryByText(/open from telegram/i)).not.toBeInTheDocument();
});
it("dev mock also engages when the CDN bridge loaded with empty initData", async () => {
// A bare browser tab still loads telegram-web-app.js, so the bridge
// object exists — only a real Telegram launch carries initData.
vi.stubEnv("NODE_ENV", "development");
waitForTelegramWebApp.mockResolvedValue(mockWebApp(""));
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
});
it("starts Telegram theme sync against the #tg-shell element once ready", async () => {
const shell = document.createElement("div");
shell.id = "tg-shell";
document.body.appendChild(shell);
try {
const webApp = mockWebApp();
waitForTelegramWebApp.mockResolvedValue(webApp);
post.mockResolvedValue({ data: { ok: true } });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(startTelegramThemeSync).toHaveBeenCalledWith(webApp, shell),
);
} finally {
shell.remove();
}
});
});