mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
feat(web): make a dead connection unmistakable instead of a red dot
Opening Codeman with nothing reachable (phone off the tailnet, VPN down, server stopped) rendered a normal-looking UI: the service worker serves the cached app shell, every /api call fails, and the only tell was an 8px red dot in the header corner. On a phone that reads as "there are no sessions". Two surfaces, chosen by whether there is anything worth looking at: - Full-screen overlay while no server state has loaded this page load. It names the host, lists the three things to check (network, VPN/Tailscale, server), counts down to the next retry, and offers "Retry now" plus "Show cached view" to demote itself to the banner. - Non-blocking banner once state HAS loaded, so a mid-session drop leaves the terminal scrollback readable. A 2.5s grace keeps a COM deploy (SSE is back in ~200ms) from flashing the banner every release; navigator.onLine === false skips the grace, since the device saying "no network" is never a blip. Retry re-arms the terminal WebSocket as well as SSE: planWsReconnect can give up outright, and the SSE backoff caps at 30s, so waiting it out is not always an option. The decision is pure (computeConnectionLossUi in constants.js, unit-tested in a node VM like the WS reconnect policy); app.js only writes the DOM.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Connection-loss UI policy.
|
||||
*
|
||||
* `CodemanConnectionLoss.compute(input)` is the pure decision behind the
|
||||
* offline banner and the full-screen "can't reach Codeman" overlay in app.js:
|
||||
* given the browser's online flag, the SSE transport status, whether server
|
||||
* state has ever loaded this page load, and how long the transport has been
|
||||
* down, it returns which surface to show and what it should say.
|
||||
*
|
||||
* The regression it guards: with the service worker serving the cached app
|
||||
* shell, an unreachable server rendered a normal-looking empty dashboard whose
|
||||
* only hint was an 8px red dot in the header corner.
|
||||
*
|
||||
* Loaded in a plain node VM context (no jsdom), mirroring
|
||||
* test/ws-reconnect-plan.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type LossInput = {
|
||||
isOnline?: boolean;
|
||||
status?: 'connected' | 'connecting' | 'reconnecting' | 'disconnected' | 'offline';
|
||||
everLoaded?: boolean;
|
||||
downSince?: number | null;
|
||||
now?: number;
|
||||
nextRetryAt?: number | null;
|
||||
overlayDismissed?: boolean;
|
||||
retryPending?: boolean;
|
||||
};
|
||||
|
||||
type LossState = {
|
||||
mode: 'hidden' | 'banner' | 'overlay';
|
||||
kind: 'connected' | 'connecting' | 'offline' | 'unreachable';
|
||||
title: string;
|
||||
detail: string;
|
||||
retryInSec: number | null;
|
||||
};
|
||||
|
||||
function loadPolicy() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanConnectionLoss: { compute: (input: LossInput) => LossState; GRACE_MS: number };
|
||||
}
|
||||
).CodemanConnectionLoss;
|
||||
}
|
||||
|
||||
const T0 = 1_000_000;
|
||||
|
||||
describe('connection-loss UI policy', () => {
|
||||
it('shows nothing while the SSE stream is connected', () => {
|
||||
const { compute } = loadPolicy();
|
||||
expect(compute({ isOnline: true, status: 'connected', everLoaded: true, now: T0 }).mode).toBe('hidden');
|
||||
});
|
||||
|
||||
it('stays hidden through a deploy-length blip (the grace window)', () => {
|
||||
const { compute, GRACE_MS } = loadPolicy();
|
||||
// A COM deploy restarts the server; SSE is back in ~200ms. Shouting on
|
||||
// every deploy would train the user to ignore the banner.
|
||||
const during = compute({
|
||||
isOnline: true,
|
||||
status: 'reconnecting',
|
||||
everLoaded: true,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS - 1,
|
||||
});
|
||||
expect(during.mode).toBe('hidden');
|
||||
expect(during.kind).toBe('connecting');
|
||||
|
||||
const after = compute({
|
||||
isOnline: true,
|
||||
status: 'reconnecting',
|
||||
everLoaded: true,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS,
|
||||
});
|
||||
expect(after.mode).toBe('banner');
|
||||
expect(after.kind).toBe('unreachable');
|
||||
});
|
||||
|
||||
it('blocks with the overlay when no server state ever loaded this page load', () => {
|
||||
const { compute, GRACE_MS } = loadPolicy();
|
||||
// The cold-start case: app shell served from the service-worker cache with
|
||||
// nothing reachable behind it. There is no UI worth preserving.
|
||||
const state = compute({
|
||||
isOnline: true,
|
||||
status: 'reconnecting',
|
||||
everLoaded: false,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS + 5000,
|
||||
});
|
||||
expect(state.mode).toBe('overlay');
|
||||
expect(state.title).toMatch(/reach the Codeman server/i);
|
||||
// The VPN/Tailscale hint is the whole point on a phone off the tailnet.
|
||||
expect(state.detail).toMatch(/Tailscale|VPN/i);
|
||||
});
|
||||
|
||||
it('uses the non-blocking banner once state has loaded, so the terminal stays readable', () => {
|
||||
const { compute, GRACE_MS } = loadPolicy();
|
||||
const state = compute({
|
||||
isOnline: true,
|
||||
status: 'disconnected',
|
||||
everLoaded: true,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS + 60_000,
|
||||
});
|
||||
expect(state.mode).toBe('banner');
|
||||
});
|
||||
|
||||
it('skips the grace window when the device itself reports no network', () => {
|
||||
const { compute } = loadPolicy();
|
||||
// navigator.onLine === false is never a 200ms blip.
|
||||
const viaFlag = compute({ isOnline: false, status: 'connecting', everLoaded: true, downSince: T0, now: T0 });
|
||||
expect(viaFlag.mode).toBe('banner');
|
||||
expect(viaFlag.kind).toBe('offline');
|
||||
expect(viaFlag.title).toMatch(/no network/i);
|
||||
|
||||
const viaStatus = compute({ isOnline: true, status: 'offline', everLoaded: false, downSince: T0, now: T0 });
|
||||
expect(viaStatus.mode).toBe('overlay');
|
||||
expect(viaStatus.kind).toBe('offline');
|
||||
});
|
||||
|
||||
it('demotes the overlay to the banner once dismissed, never back to hidden', () => {
|
||||
const { compute, GRACE_MS } = loadPolicy();
|
||||
const base: LossInput = {
|
||||
isOnline: true,
|
||||
status: 'reconnecting',
|
||||
everLoaded: false,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS + 1000,
|
||||
};
|
||||
expect(compute(base).mode).toBe('overlay');
|
||||
expect(compute({ ...base, overlayDismissed: true }).mode).toBe('banner');
|
||||
});
|
||||
|
||||
it('counts down to the next scheduled retry, floored at zero', () => {
|
||||
const { compute, GRACE_MS } = loadPolicy();
|
||||
const at = (nextRetryAt: number | null, extra: Partial<LossInput> = {}) =>
|
||||
compute({
|
||||
isOnline: true,
|
||||
status: 'reconnecting',
|
||||
everLoaded: true,
|
||||
downSince: T0,
|
||||
now: T0 + GRACE_MS,
|
||||
nextRetryAt,
|
||||
...extra,
|
||||
}).retryInSec;
|
||||
|
||||
expect(at(T0 + GRACE_MS + 4000)).toBe(4);
|
||||
expect(at(T0 + GRACE_MS + 4001)).toBe(5); // rounds up, never shows "0s" while waiting
|
||||
expect(at(T0)).toBe(0); // already overdue
|
||||
expect(at(null)).toBeNull(); // no retry scheduled -> indeterminate label
|
||||
// A user-triggered retry has no scheduled time; the caller renders "Reconnecting…".
|
||||
expect(at(T0 + GRACE_MS + 4000, { retryPending: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a missing downSince as freshly down rather than long-dead', () => {
|
||||
const { compute } = loadPolicy();
|
||||
const state = compute({ isOnline: true, status: 'connecting', everLoaded: false, downSince: null, now: T0 });
|
||||
expect(state.mode).toBe('hidden');
|
||||
});
|
||||
|
||||
it('tolerates an empty input', () => {
|
||||
const { compute } = loadPolicy();
|
||||
expect(compute({}).mode).toBe('hidden');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user