Files
cinny/e2e/server-status.spec.ts
T
Lotus CIandClaude Opus 5.5 6ae0087213
CI / Build & Quality Checks (pull_request) Successful in 4m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m58s
feat: homeserver status banner from Uptime Kuma (#124)
When the homeserver, voice calls or sign-in break, or maintenance is under
way, say so in the app — driven by the Kuma status page
(isitup.lotusguild.org/status/matrix), managed from Kuma's UI. The client
asks Kuma directly (not via our servers) so it still hears "the server is
down" when our servers can't tell it.

- config.json `statusPages`, keyed by homeserver: users of other servers
  never contact Kuma.
- utils/kumaStatus.ts (pure, unit-tested): parse Kuma 2.x's public JSON;
  a group is down when any monitor fails two checks in a row (down+down or
  pending+down); maintenance = windows under way; announcements = incidents.
  One strip at a time: server down (connection lost AND Kuma confirms) >
  server having problems > maintenance > calls down > announcement;
  sign-in problems on the login screen only.
- Wording about the user's own connection: "Connection lost … our status
  checks say the server is up, so it may be your internet connection" ONLY
  when Kuma checked the server after this client's connection dropped and
  it passed; a stale "up" (Kuma needs a minute or two to notice an outage)
  keeps the plain "Connection Lost!".
- useServerStatus: polls only while visible; 5 min, 60 s while something is
  wrong or the connection is lost, at once when it drops; backoff; any
  failure = no banner (Kuma being unreachable never looks like Matrix
  being down); GET only, no cookies.
- UI in the existing banner slot and style (ContainerColor/Line like the
  sync and clock banners); Details expands; dismiss for calls-down and
  announcements (an edited announcement comes back); calls-down note above
  Join; phone: one line + Details.

Needs the CSP connect-src to allow https://isitup.lotusguild.org (matrix
repo) before it can fetch in production; until then it fails quiet.

Tests: 15 unit tests (live page layout, two-check rule, any-monitor rule,
unknown/garbage, UTC beat times, stale-vs-fresh "up", priorities, login vs
client, maintenance, announcements + dismiss/edit); e2e (fixtures for Kuma):
calls-down strip + dismiss across reload, other homeservers make no
requests, Kuma 500 → nothing, lost connection + Kuma down → critical strip
instead of "Connection Lost", + fresh "up" → "may be your connection",
+ stale "up" → plain "Connection Lost". Unit 1315, Playwright 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 12:08:08 -04:00

163 lines
5.8 KiB
TypeScript

import { test, expect, Page } from '@playwright/test';
import {
HS,
createRoom,
ensureUser,
hsReachable,
loginUI,
openRoom,
uniq,
TestUser,
} from './localHs';
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
// answered by fixtures here; nothing contacts a real Kuma.
const KUMA = 'https://isitup.lotusguild.org';
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
const UP = 1;
const DOWN = 0;
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
const kumaRequests: string[] = [];
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
const res = await route.fetch();
const cfg = await res.json();
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
await route.fulfill({ response: res, json: cfg });
});
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
const url = route.request().url();
kumaRequests.push(url);
const f = fx();
if (f.fail) {
await route.fulfill({ status: 500, body: 'oops' });
return;
}
const cors = { 'access-control-allow-origin': '*' };
if (url.includes('/heartbeat/')) {
// Two checks, the latest `staleMinutes` ago (0 = just now).
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
const beats = (s: number) => [
{ status: s, time: kumaTime(last - 60_000) },
{ status: s, time: kumaTime(last) },
];
await route.fulfill({
headers: cors,
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
});
return;
}
await route.fulfill({
headers: cors,
json: {
config: { slug: 'matrix' },
incidents: [],
maintenanceList: [],
publicGroupList: [
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
],
},
});
});
return kumaRequests;
}
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
const strip = (page: Page, text: RegExp) =>
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
const cutSync = (page: Page) =>
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
test.describe('server status banner (#124)', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
alice = await ensureUser(uniq('e2e_status_'));
room = await createRoom(alice, 'Status Room');
});
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
await expect(calls).toBeVisible();
await calls.getByRole('button', { name: 'Dismiss' }).click();
await expect(calls).toHaveCount(0);
await page.reload();
await openRoom(page, room);
await page.waitForTimeout(2000);
await expect(calls).toHaveCount(0);
});
test('other homeservers never contact Kuma', async ({ page }) => {
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
await page.waitForTimeout(3000);
expect(requests).toEqual([]);
});
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
await loginUI(page, alice);
await openRoom(page, room);
await expect.poll(() => requests.length).toBeGreaterThan(0);
await page.waitForTimeout(1500);
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
});
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
await loginUI(page, alice);
await openRoom(page, room);
// Connected: the server is "having problems" for others, not for us.
await expect(strip(page, /server is having problems/)).toBeVisible();
await cutSync(page);
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
await expect(down).toBeVisible({ timeout: 120_000 });
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
});
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
{
timeout: 120_000,
},
);
});
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
page,
}) => {
test.setTimeout(150_000);
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
await loginUI(page, alice);
await openRoom(page, room);
await cutSync(page);
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
timeout: 120_000,
});
await page.waitForTimeout(3000);
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
});
});