mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The upstream v1.1.15 merge spliced upstream's transport-object indicator
body onto local's _connectionStatus-based _updateConnectionIndicator()
without defining `transport`, so every transport.* reference threw
ReferenceError on any queued state. That hid the "WS" status and, because
_reliableSend() updates the indicator before _drainSession(), made every
keystroke skip immediate delivery (input flushed only on the 2s sweep =
typing lag).
- Rewrite _updateConnectionIndicator() to show the terminal WebSocket
transport from _wsState (WS / HTTP / WS… / Offline), falling back to the
SSE _connectionStatus only on the idle dashboard.
- Only annotate a backlog (· N queued) above 4 bytes so normal typing no
longer flickers "sending 1B" on each key press.
- test/connection-indicator.test.ts (new): transport labels, the >4B
threshold, an exhaustive never-throws guard for the ReferenceError, and
the _reliableSend -> _drainSession invariant (typing-lag guard).
- test/input-send-order.test.ts: reconcile to local's durable input layer
(the prior coalescing-fallback tests had been failing since 1255e28).
160 lines
6.0 KiB
TypeScript
160 lines
6.0 KiB
TypeScript
/**
|
|
* @fileoverview Input dispatch ordering for the durable, acknowledged delivery
|
|
* layer (`CodemanApp._sendInputAsync` → `_reliableSend` → `_drainSession`).
|
|
*
|
|
* Local replaced the upstream best-effort "coalescing fallback queue" with the
|
|
* durable per-(clientId, seq) layer in commit 1255e28 (docs/reliable-input-
|
|
* delivery.md). This suite verifies the client-side ordering guarantees of that
|
|
* layer: each input is a distinct seq-tagged frame, delivered in order over the
|
|
* WebSocket when open, serialized over HTTP POST when not, and only dropped on a
|
|
* server ACK (HTTP 2xx). Exactly-once application is covered server-side in
|
|
* test/reliable-input-dedup.test.ts; the header transport indicator ("WS"/"HTTP")
|
|
* is covered in test/connection-indicator.test.ts.
|
|
*
|
|
* Loaded via `vm` with a stubbed context (no jsdom).
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
function loadCodemanAppClass() {
|
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
|
const context = vm.createContext({
|
|
console,
|
|
performance,
|
|
setInterval: vi.fn(),
|
|
clearInterval: vi.fn(),
|
|
setTimeout,
|
|
clearTimeout,
|
|
requestAnimationFrame: vi.fn(),
|
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
|
WebSocket: { OPEN: 1 },
|
|
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
|
document: { addEventListener: vi.fn() },
|
|
localStorage: {
|
|
length: 0,
|
|
key: vi.fn(),
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
},
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
MobileDetection: {},
|
|
});
|
|
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
|
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
|
}
|
|
|
|
const CodemanApp = loadCodemanAppClass();
|
|
|
|
async function waitForCalls(calls: unknown[], count: number) {
|
|
for (let i = 0; i < 50; i++) {
|
|
if (calls.length >= count) return;
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
}
|
|
|
|
type Frame = { t: string; d: string; seq: number; cid: string };
|
|
type PostBody = { input: string; seq: number; clientId: string };
|
|
|
|
type App = {
|
|
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
|
_pendingDeliveries: Map<string, Array<{ seq: number; data: string; sentAt: number }>>;
|
|
_ws: { readyState: number; send: (data: string) => void } | null;
|
|
_wsSessionId: string | null;
|
|
activeSessionId: string | null;
|
|
};
|
|
|
|
function makeApp(): App {
|
|
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
|
app._clientId = 'c-test';
|
|
app._seqCounters = new Map();
|
|
app._pendingDeliveries = new Map();
|
|
app._postDraining = new Set();
|
|
app._persistReliableState = vi.fn();
|
|
app._persistReliableNow = vi.fn();
|
|
app._updateConnectionIndicator = vi.fn();
|
|
app.clearPendingHooks = vi.fn();
|
|
app.activeSessionId = 'session-1';
|
|
app.isOnline = true;
|
|
app._connectionStatus = 'connected';
|
|
app._ws = null;
|
|
app._wsSessionId = null;
|
|
return app as unknown as App;
|
|
}
|
|
|
|
describe('durable input delivery — send ordering', () => {
|
|
it('delivers rapid input as distinct ordered seq frames over an open WebSocket (no coalescing)', () => {
|
|
const app = makeApp();
|
|
const frames: Frame[] = [];
|
|
app._ws = { readyState: 1, send: (d: string) => frames.push(JSON.parse(d)) };
|
|
app._wsSessionId = 'session-1';
|
|
|
|
app._sendInputAsync('session-1', 'a');
|
|
app._sendInputAsync('session-1', 'b');
|
|
app._sendInputAsync('session-1', 'c');
|
|
|
|
// Each keystroke is its own frame, in seq order — never merged into "abc".
|
|
expect(frames.map((f) => f.d)).toEqual(['a', 'b', 'c']);
|
|
expect(frames.map((f) => f.seq)).toEqual([1, 2, 3]);
|
|
expect(frames.every((f) => f.t === 'i' && f.cid === 'c-test')).toBe(true);
|
|
});
|
|
|
|
it('POSTs queued input one frame at a time in seq order when no socket is open', async () => {
|
|
const app = makeApp();
|
|
const calls: PostBody[] = [];
|
|
const completions: Array<() => void> = [];
|
|
global.fetch = vi.fn(async (_url, init) => {
|
|
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
|
await new Promise<void>((r) => completions.push(r));
|
|
return new Response('{}', { status: 200 });
|
|
});
|
|
|
|
app._sendInputAsync('session-1', 'a');
|
|
app._sendInputAsync('session-1', 'b');
|
|
|
|
// Serialized: only the first frame is in flight until its 2xx ACK lands.
|
|
await waitForCalls(calls, 1);
|
|
expect(calls.map((c) => c.input)).toEqual(['a']);
|
|
|
|
completions.shift()?.(); // ACK 'a'
|
|
await waitForCalls(calls, 2);
|
|
expect(calls.map((c) => c.input)).toEqual(['a', 'b']);
|
|
expect(calls.map((c) => c.seq)).toEqual([1, 2]);
|
|
|
|
completions.shift()?.();
|
|
await waitForCalls(calls, 2);
|
|
});
|
|
|
|
it('leaves a frame queued (unacked) when HTTP delivery fails', async () => {
|
|
const app = makeApp();
|
|
const calls: PostBody[] = [];
|
|
global.fetch = vi.fn(async (_url, init) => {
|
|
calls.push(JSON.parse(String(init?.body)) as PostBody);
|
|
return new Response('busy', { status: 503 });
|
|
});
|
|
|
|
app._sendInputAsync('session-1', 'a');
|
|
await waitForCalls(calls, 1);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
// 5xx is not an ACK — the frame must survive for the sweep/reconnect to retry.
|
|
expect(app._pendingDeliveries.get('session-1')).toHaveLength(1);
|
|
expect(app._pendingDeliveries.get('session-1')?.[0].data).toBe('a');
|
|
});
|
|
|
|
it('drops a frame addressed to a vanished session (404) instead of retrying forever', async () => {
|
|
const app = makeApp();
|
|
global.fetch = vi.fn(async () => new Response('gone', { status: 404 }));
|
|
|
|
app._sendInputAsync('session-1', 'a');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(app._pendingDeliveries.get('session-1')).toBeUndefined();
|
|
});
|
|
});
|