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
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
163 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|