Ctrl K
ComunidadGuías…

test/client-explorer-collapse.test.js

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

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
// terminal.js pulls in xterm; the explorer only needs switchToSession from it.
vi.mock('../public/modules/terminal.js', () => ({ switchToSession: vi.fn(), restartAgent: vi.fn(), restartPending: () => false }));

import { send } from '../public/modules/ws.js';
import { agents, repos, orphans, setActiveSession } from '../public/modules/state.js';
import { renderExplorer, handleRepoRemovalPreview } from '../public/modules/explorer.js';

// collapsedRepos is module-level state in explorer.js and survives between
// tests (deliberately: collapse must persist across re-renders), so each test
// uses its own unique repoPath to stay independent.
const EXPLORER_HTML = `
  <div id="explorer-content"></div>
  <div class="spawn-form" id="spawn-form" style="display:none">
    <input type="text" id="spawn-repo">
    <input type="text" id="spawn-name">
  </div>`;

const AGENT = (repoPath, over = {}) => ({
  name: 'Viper', state: 'IDLE', repoPath, branchName: 'branch/viper', color: '#fff',
  ...over,
});

beforeEach(() => {
  document.body.innerHTML = EXPLORER_HTML;
  agents.clear();
  repos.clear();
  orphans.clear();
  setActiveSession(null);
  vi.clearAllMocks();
});

function section(slugText) {
  return [...document.querySelectorAll('.explorer-repo')]
    .find(s => s.querySelector('.explorer-repo-header').textContent.includes(slugText));
}
function headerOf(slugText) {
  return section(slugText).querySelector('.explorer-repo-header');
}

describe('explorer repo section collapse/expand', () => {
  it('configured repo: header click collapses, second click re-expands', () => {
    repos.set('/repos/alpha', { slug: 'alpha', exists: true });
    agents.set('s1', AGENT('/repos/alpha'));
    renderExplorer();

    expect(section('alpha').querySelectorAll('.explorer-branch')).toHaveLength(1);
    expect(headerOf('alpha').textContent).toContain('▾'); // ▾ expanded

    headerOf('alpha').click();
    expect(section('alpha').querySelectorAll('.explorer-branch')).toHaveLength(0);
    expect(headerOf('alpha').textContent).toContain('▸'); // ▸ collapsed

    headerOf('alpha').click();
    expect(section('alpha').querySelectorAll('.explorer-branch')).toHaveLength(1);
    expect(headerOf('alpha').textContent).toContain('▾');
  });

  it('collapsing hides the "(no agents)" placeholder too', () => {
    repos.set('/repos/beta', { slug: 'beta', exists: true });
    renderExplorer();
    expect(section('beta').querySelector('.explorer-no-agents')).not.toBeNull();

    headerOf('beta').click();
    expect(section('beta').querySelector('.explorer-no-agents')).toBeNull();
  });

  it('collapsed state persists across re-renders', () => {
    repos.set('/repos/gamma', { slug: 'gamma', exists: true });
    agents.set('s1', AGENT('/repos/gamma'));
    renderExplorer();

    headerOf('gamma').click();
    renderExplorer(); // e.g. a file-tree update re-renders the panel
    renderExplorer();
    expect(section('gamma').querySelectorAll('.explorer-branch')).toHaveLength(0);
    expect(headerOf('gamma').textContent).toContain('▸');
  });

  it('+ button opens the spawn form without toggling the section', () => {
    repos.set('/repos/delta', { slug: 'delta', exists: true });
    agents.set('s1', AGENT('/repos/delta'));
    renderExplorer();

    headerOf('delta').querySelector('.explorer-icon-btn').click();
    expect(document.getElementById('spawn-form').style.display).toBe('flex');
    expect(document.getElementById('spawn-repo').value).toBe('/repos/delta');
    // stopPropagation: section stays expanded
    expect(section('delta').querySelectorAll('.explorer-branch')).toHaveLength(1);
  });

  it('× button asks the server first, without toggling the section', () => {
    repos.set('/repos/epsilon', { slug: 'epsilon', exists: true });
    agents.set('s1', AGENT('/repos/epsilon'));
    renderExplorer();

    headerOf('epsilon').querySelector('.explorer-remove-btn').click();
    expect(send).toHaveBeenCalledWith({ type: 'remove-repo-check', path: '/repos/epsilon' });
    expect(send).not.toHaveBeenCalledWith({ type: 'remove-repo', path: '/repos/epsilon' });
    expect(section('epsilon').querySelectorAll('.explorer-branch')).toHaveLength(1);
  });

  it('orphan-only repo section collapses and re-expands', () => {
    orphans.set('o1', { id: 'o1', repoPath: '/repos/zeta', repoSlug: 'zeta', name: 'Ghost', reason: 'stale' });
    renderExplorer();
    expect(section('zeta').querySelector('.explorer-orphan')).not.toBeNull();

    headerOf('zeta').click();
    expect(section('zeta').querySelector('.explorer-orphan')).toBeNull();
    expect(headerOf('zeta').textContent).toContain('▸');

    headerOf('zeta').click();
    expect(section('zeta').querySelector('.explorer-orphan')).not.toBeNull();
  });

  it('collapsed header shows agent count and most urgent state dot', () => {
    repos.set('/repos/eta', { slug: 'eta', exists: true });
    agents.set('s1', AGENT('/repos/eta', { state: 'IDLE' }));
    agents.set('s2', AGENT('/repos/eta', { name: 'Vesper', state: 'MESSAGE' }));
    renderExplorer();
    expect(section('eta').querySelector('.explorer-collapsed-badge')).toBeNull();

    headerOf('eta').click();
    const badge = section('eta').querySelector('.explorer-collapsed-badge');
    expect(badge.textContent).toContain('2');
    expect(badge.querySelector('.explorer-dot').style.background).toBe('var(--state-message)');
  });

  it('header is keyboard-operable and exposes aria-expanded', () => {
    repos.set('/repos/theta', { slug: 'theta', exists: true });
    renderExplorer();
    expect(headerOf('theta').getAttribute('aria-expanded')).toBe('true');

    headerOf('theta').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
    expect(headerOf('theta').getAttribute('aria-expanded')).toBe('false');
    expect(headerOf('theta').textContent).toContain('▸');
  });

  it('Enter on a nested header button does not toggle the section', () => {
    repos.set('/repos/kappa', { slug: 'kappa', exists: true });
    agents.set('s1', AGENT('/repos/kappa'));
    renderExplorer();

    headerOf('kappa').querySelector('.explorer-icon-btn')
      .dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
    expect(headerOf('kappa').getAttribute('aria-expanded')).toBe('true');
    expect(section('kappa').querySelectorAll('.explorer-branch')).toHaveLength(1);
  });

  it('keyboard toggle keeps focus on the header across the re-render', () => {
    repos.set('/repos/lambda', { slug: 'lambda', exists: true });
    renderExplorer();

    headerOf('lambda').focus();
    headerOf('lambda').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
    expect(document.activeElement).toBe(headerOf('lambda'));
    // and toggling works repeatedly
    headerOf('lambda').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
    expect(headerOf('lambda').getAttribute('aria-expanded')).toBe('true');
  });

  it('removing a repo clears its collapsed state', () => {
    repos.set('/repos/iota', { slug: 'iota', exists: true });
    renderExplorer();
    headerOf('iota').click();
    expect(headerOf('iota').textContent).toContain('▸');

    headerOf('iota').querySelector('.explorer-remove-btn').click();
    // Re-added later: starts expanded, not mysteriously collapsed
    renderExplorer();
    expect(headerOf('iota').textContent).toContain('▾');
  });

  it('collapsed state is written to localStorage so a refresh keeps it', () => {
    repos.set('/repos/mu', { slug: 'mu', exists: true });
    renderExplorer();

    headerOf('mu').click();
    expect(JSON.parse(localStorage.getItem('agent007-collapsed-repos'))).toContain('/repos/mu');

    headerOf('mu').click();
    expect(JSON.parse(localStorage.getItem('agent007-collapsed-repos'))).not.toContain('/repos/mu');
  });

  it('"No repo" section collapses and re-expands', () => {
    agents.set('s1', AGENT(null, { name: 'Loner' }));
    renderExplorer();
    expect(section('No repo').querySelectorAll('.explorer-branch')).toHaveLength(1);

    headerOf('No repo').click();
    expect(section('No repo').querySelectorAll('.explorer-branch')).toHaveLength(0);
    expect(headerOf('No repo').textContent).toContain('▸');

    headerOf('No repo').click();
    expect(section('No repo').querySelectorAll('.explorer-branch')).toHaveLength(1);
  });
});

describe('Restart in the left panel', () => {
  it('is on an exited agent\'s row and an orphan\'s, and not on a running one', async () => {
    const { restartAgent } = await import('../public/modules/terminal.js');
    repos.set('/repos/restart', { slug: 'restart', exists: true });
    agents.set('s-live', AGENT('/repos/restart', { name: 'Live', state: 'WORKING' }));
    agents.set('s-dead', AGENT('/repos/restart', { name: 'Dead', state: 'DISCONNECTED', restartable: true }));
    orphans.set('o1', { id: 'o1', name: 'Gone', repoPath: '/repos/restart', reason: 'server-restart' });
    renderExplorer();
    const buttons = [...section('restart').querySelectorAll('.explorer-restart-btn')];
    expect(buttons.map(b => b.textContent)).toEqual(['Restart', 'Restart']);
    expect(section('restart').textContent).not.toContain('Re-spawn');
    buttons[0].click();
    buttons[1].click();
    expect(restartAgent).toHaveBeenCalledWith({ sessionId: 's-dead' });
    expect(restartAgent).toHaveBeenCalledWith({ orphanId: 'o1' });
  });
});

describe('remove-repo dialog', () => {
  const PREVIEW = (agents) => ({ type: 'repo-removal-preview', path: '/repos/eta', slug: 'eta', agents });
  const dialog = () => document.getElementById('remove-repo-dialog');
  beforeEach(() => {
    // jsdom has no <dialog> modal support.
    HTMLDialogElement.prototype.showModal ||= function () { this.setAttribute('open', ''); };
    HTMLDialogElement.prototype.close = function () { this.removeAttribute('open'); this.dispatchEvent(new Event('close')); };
  });

  it('names what goes, warns about lost work, and focuses Cancel', () => {
    handleRepoRemovalPreview(PREVIEW([
      { name: 'Cipher', kind: 'agent', uncommitted: false, unpushed: 0 },
      { name: 'Raven', kind: 'agent', uncommitted: false, unpushed: 3 },
      { name: 'Ghost', kind: 'orphan', uncommitted: true, unpushed: 0 },
    ]));
    const text = dialog().textContent;
    expect(text).toContain('Remove eta from Agent 007?');
    expect(text).toContain('closes its 2 agents (Cipher, Raven)');
    expect(text).toContain('The repo folder itself is not touched.');
    expect(text).toContain('Raven has 3 unpushed commits; they will be lost.');
    expect(text).toContain('Ghost has uncommitted changes; they will be lost.');
    expect(text).not.toContain('Cipher has');
    expect(document.activeElement.textContent).toBe('Cancel');
  });

  it('Cancel sends nothing; confirm sends remove-repo', () => {
    handleRepoRemovalPreview(PREVIEW([]));
    [...dialog().querySelectorAll('button')].find(b => b.textContent === 'Cancel').click();
    expect(dialog()).toBeNull();
    expect(send).not.toHaveBeenCalled();

    handleRepoRemovalPreview(PREVIEW([]));
    [...dialog().querySelectorAll('button')].find(b => b.textContent === 'Remove repo').click();
    expect(send).toHaveBeenCalledWith({ type: 'remove-repo', path: '/repos/eta' });
  });

  it('labels a repo that is not on the board, with no controls', () => {
    orphans.set('o9', { id: 'o9', repoPath: '/repos/theta', repoSlug: 'theta', name: 'Ghost', reason: 'stale' });
    renderExplorer();
    expect(headerOf('theta').textContent).toContain('theta · not on the board');
    expect(headerOf('theta').querySelector('.explorer-remove-btn')).toBeNull();
  });
});