Ctrl K
ComunidadGuías…

test/client-slow-link.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom
// A slow or dropped link (remote, over a relay): the box's send outlasts its
// timeout, the socket's heartbeat, a reconnect that keeps the page, the draft
// across a restart's reload, and the stalled badge fed by pty-activity for a
// terminal not on screen.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const DOM = '<div id="reconnecting"></div><div id="terminal-tabs"></div><div id="terminal-empty"></div><div id="job-board"></div>'
  + '<div id="waiting-board" style="display:none"><div id="waiting-list"></div></div>';

// A fresh page: modules as a reload would load them, with ws.js stubbed.
async function page({ up = true } = {}) {
  vi.resetModules();
  const link = { up };
  const send = vi.fn(() => link.up);
  vi.doMock('../public/modules/ws.js', () => ({ send, connected: () => link.up }));
  document.body.innerHTML = DOM;
  const state = await import('../public/modules/state.js');
  const waiting = await import('../public/modules/waiting.js');
  state.agents.set('b', { isBillion: true });
  waiting.showWaiting();
  return { link, send, state, waiting };
}
const input = () => document.getElementById('chat-input');
const error = () => document.getElementById('chat-error');
const type = (text) => {
  input().value = text;
  input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
};
const owner = (nonce, text) => ({ id: `m-${nonce}`, from: 'owner', via: 'app', text, at: new Date().toISOString(), utterance: `chat-${nonce}` });

beforeEach(() => { vi.useFakeTimers(); sessionStorage.clear(); });
afterEach(() => { vi.useRealTimers(); vi.doUnmock('../public/modules/ws.js'); });

describe('a send on a slow link', () => {
  it('keeps waiting while the socket is up, and a late chat-sent still finishes it', async () => {
    const { send, waiting } = await page();
    type('hello');
    const { nonce } = send.mock.calls[0][0];
    vi.advanceTimersByTime(60_000);
    expect(error().hidden).toBe(true);
    expect(document.getElementById('chat-send').disabled).toBe(true);
    waiting.handleChatSent({ nonce });
    expect(input().value).toBe('');
  });

  it('takes the message arriving in the thread as sent', async () => {
    const { send, waiting } = await page();
    type('hello');
    const { nonce } = send.mock.calls[0][0];
    waiting.handleChatMessage(owner(nonce, 'hello'));
    expect(input().value).toBe('');
    expect(document.getElementById('chat-send').disabled).toBe(false);
  });

  it('says so when the socket dropped, keeps the text, and sends it again under the same nonce', async () => {
    const { send, link, waiting } = await page();
    type('hello');
    const first = send.mock.calls[0][0].nonce;
    link.up = false;
    vi.advanceTimersByTime(20_000);
    expect(error().textContent).toBe('Not sent: connection dropped.');
    expect(input().value).toBe('hello');
    link.up = true;
    type('hello');
    expect(send.mock.calls[1][0].nonce).toBe(first);   // the server takes it as the one already in
    waiting.handleChatSent({ nonce: first });
    type('hello again');
    expect(send.mock.calls[2][0].nonce).not.toBe(first);
  });
});

describe("the draft across a restart's reload", () => {
  it('brings back the text and files, and clears them once the thread shows the send went in', async () => {
    const first = await page();
    first.waiting.attachFiles([new File(['xyz'], 'a.pdf', { type: 'application/pdf' })]);
    await vi.waitFor(() => {
      type('did it go?');
      expect(first.send).toHaveBeenCalled();
    });
    const { nonce } = first.send.mock.calls[0][0];
    window.dispatchEvent(new Event('pagehide'));

    const second = await page();
    expect(input().value).toBe('did it go?');
    expect(document.querySelectorAll('#chat-files .chat-file-chip')).toHaveLength(1);
    second.state.setChatMessages([owner(nonce, 'did it go?')]);
    second.waiting.settleFromThread();
    expect(input().value).toBe('');
    expect(document.querySelectorAll('#chat-files .chat-file-chip')).toHaveLength(0);
  });

  it('says it was not sent when the thread lacks it, and a resend keeps the nonce', async () => {
    const first = await page();
    type('lost?');
    const { nonce } = first.send.mock.calls[0][0];
    window.dispatchEvent(new Event('pagehide'));

    const second = await page();
    second.state.setChatMessages([]);
    second.waiting.settleFromThread();
    expect(error().textContent).toBe('Not sent: connection dropped.');
    type('lost?');
    expect(second.send.mock.calls[0][0].nonce).toBe(nonce);
  });

  it('keeps a plain draft that was never sent', async () => {
    await page();
    input().value = 'half a thought';
    window.dispatchEvent(new Event('pagehide'));
    await page();
    expect(input().value).toBe('half a thought');
    expect(error().hidden).toBe(true);
  });
});

describe('the socket heartbeat', () => {
  it('pings, and drops a socket that has gone quiet so the reconnect runs', async () => {
    vi.resetModules();
    vi.doUnmock('../public/modules/ws.js');
    const sockets = [];
    vi.stubGlobal('WebSocket', class {
      constructor() { this.readyState = 1; this.send = vi.fn(); this.close = vi.fn(); sockets.push(this); }
    });
    vi.stubGlobal('location', { protocol: 'http:', host: 'localhost', reload: vi.fn() });
    document.body.innerHTML = DOM;
    const { connect, connected, PING_MS, DEAD_MS } = await import('../public/modules/ws.js');
    const heard = vi.fn();
    connect(heard);
    sockets[0].onopen();
    vi.advanceTimersByTime(PING_MS);
    expect(sockets[0].send).toHaveBeenCalledWith('{"type":"ping"}');
    sockets[0].onmessage({ data: '{"type":"pong"}' });
    expect(heard).not.toHaveBeenCalled();   // answered here, not by the app
    expect(connected()).toBe(true);

    // A background tab's timers run once a minute: a long quiet gap with
    // every ping answered is not a dead socket.
    vi.setSystemTime(Date.now() + 5 * 60 * 1000);
    vi.advanceTimersByTime(PING_MS);
    expect(sockets[0].send).toHaveBeenCalledTimes(2);
    expect(connected()).toBe(true);

    // That ping goes unanswered: dropped, and the reconnect runs.
    vi.advanceTimersByTime(2 * PING_MS);   // the second tick finds it past DEAD_MS
    expect(connected()).toBe(false);
    expect(sockets[0].close).toHaveBeenCalled();
    expect(document.getElementById('reconnecting').style.display).toBe('block');
    vi.advanceTimersByTime(1000);
    expect(sockets).toHaveLength(2);
    vi.unstubAllGlobals();
  });
});

// Behind a proxy (Tailscale Serve, a tunnel) a socket drops on an idle
// timeout or a blip: the page reconnects in place, and reloads only when the
// server it reaches is a different boot (a restart or an update).
describe('a reconnect', () => {
  async function socket() {
    vi.resetModules();
    vi.doUnmock('../public/modules/ws.js');
    const sockets = [];
    vi.stubGlobal('WebSocket', class {
      constructor() { this.readyState = 1; this.send = vi.fn(); this.close = vi.fn(); sockets.push(this); }
    });
    const reload = vi.fn();
    vi.stubGlobal('location', { protocol: 'https:', host: 'mac.tailnet.ts.net', reload });
    document.body.innerHTML = DOM;
    const { connect } = await import('../public/modules/ws.js');
    const heard = vi.fn();
    connect(heard);
    const welcome = (bootId) => sockets.at(-1).onmessage({ data: JSON.stringify({ type: 'welcome', bootId, sessionIds: [] }) });
    const drop = () => sockets.at(-1).onclose({ code: 1006 });
    return { sockets, reload, heard, welcome, drop };
  }
  afterEach(() => vi.unstubAllGlobals());

  it('keeps the page across drops to the same server, and says it reconnected', async () => {
    const { sockets, reload, heard, welcome, drop } = await socket();
    sockets[0].onopen();
    welcome('boot-1');
    expect(heard).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'welcome', reconnected: false }));
    for (let i = 1; i <= 3; i++) {
      drop();
      expect(document.getElementById('reconnecting').style.display).toBe('block');
      vi.advanceTimersByTime(1000);   // a server that answered resets the backoff
      expect(sockets).toHaveLength(i + 1);
      sockets[i].onopen();
      expect(document.getElementById('reconnecting').style.display).toBe('none');
      welcome('boot-1');
      expect(heard).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'welcome', reconnected: true }));
    }
    expect(reload).not.toHaveBeenCalled();
  });

  it('reloads exactly once when the server restarted, and passes on nothing after', async () => {
    const { sockets, reload, heard, welcome, drop } = await socket();
    sockets[0].onopen();
    welcome('boot-1');
    drop();
    vi.advanceTimersByTime(1000);
    sockets[1].onopen();
    expect(reload).not.toHaveBeenCalled();   // the open alone proves nothing
    heard.mockClear();
    welcome('boot-2');
    expect(reload).toHaveBeenCalledTimes(1);
    expect(heard).not.toHaveBeenCalled();
    expect(sockets[1].onmessage).toBeNull();
  });

  it('backs off, to a ceiling, while sockets open and close without a server answering', async () => {
    const { sockets, drop } = await socket();
    const waits = [];
    for (let i = 0; i < 8; i++) {
      sockets.at(-1).onopen();
      drop();
      const before = sockets.length;
      let waited = 0;
      while (sockets.length === before) { vi.advanceTimersByTime(1000); waited += 1000; }
      waits.push(waited);
    }
    expect(waits).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
  });
});

describe('a terminal not on screen', () => {
  it('feeds the stalled badge from pty-activity, and a replay is not activity', async () => {
    vi.resetModules();
    vi.doMock('../public/modules/ws.js', () => ({ send: vi.fn(() => true), connected: () => true }));
    const { agents } = await import('../public/modules/state.js');
    const { handlePtyActivity, handlePtyOutput } = await import('../public/modules/terminal.js');
    const term = { write: vi.fn(), reset: vi.fn() };
    const stale = Date.now() - 4 * 60 * 1000;   // past STALLED_AFTER_MS
    agents.set('s1', { state: 'WAITING', lastOutputAt: stale, term });

    handlePtyOutput({ sessionId: 's1', data: btoa('old screen'), replay: true, reset: true });
    handlePtyOutput({ sessionId: 's1', data: btoa('more of it'), replay: true });   // a long scrollback's next chunk
    expect(term.reset).toHaveBeenCalledTimes(1);
    expect(agents.get('s1').lastOutputAt).toBe(stale);

    handlePtyActivity({ sessionId: 's1' });
    expect(Date.now() - agents.get('s1').lastOutputAt).toBeLessThan(1000);
    handlePtyOutput({ sessionId: 's1', data: btoa('new') });
    expect(term.reset).toHaveBeenCalledTimes(1);
    expect(() => handlePtyActivity({ sessionId: 'gone' })).not.toThrow();
  });
});