mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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.
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|