Files
arboretum/packages/server/test/discovery-service.test.ts
Johan LEROY dacff2b98f feat(sessions): masquer les sessions externes + terminal plein écran
Masquage persistant des sessions Claude découvertes (lancées hors Arboretum)
qui polluent la liste — calqué sur repos.hidden :
- table hidden_sessions (#9) clé claudeSessionId ; helpers DB
- DiscoveryService pose le champ additif SessionSummary.hidden (resume/fork
  restent possibles, survit à un re-scan)
- routes POST/DELETE /sessions/:id/hide, POST /sessions/hide-discovered
  (masse, un clic), GET /sessions?includeHidden=true
- UI : bouton Masquer par ligne, « Masquer l'historique externe », toggle
  « Afficher les masquées », badge masquée ; store + i18n EN/FR + audit

Terminal plein écran (corrige le sizing) :
- SessionView root s'étire (flex-1) → xterm calcule les bonnes dimensions
- refit robuste (requestAnimationFrame + après resync), h-full sur le wrapper
- bouton plein écran (API Fullscreen), tabbar mobile masquée en fullbleed

DialogPrompt : options en liste verticale pleine largeur (plus de troncature
ni de chevauchement sur les libellés longs / multi-lignes).

Doc : centre d'aide (sessions), README EN/FR, site (showcase), CLAUDE.md.
Tests : discovery (hidden) + route hide/unhide/hide-discovered.
2026-06-22 16:02:27 +02:00

135 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { SessionSummary } from '@arboretum/shared';
import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.js';
import { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js';
import { PtyManager } from '../src/core/pty-manager.js';
import { openDb, hideSession, unhideSession, listHiddenSessionIds, type Db } from '../src/db/index.js';
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
const dir = join(projectsDir, munge(cwd));
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, `${sid}.jsonl`),
`${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`,
);
}
describe('DiscoveryService', () => {
let projectsDir: string;
let sessionsDir: string;
let db: Db;
let manager: PtyManager;
let svc: DiscoveryService;
beforeEach(() => {
projectsDir = mkdtempSync(join(tmpdir(), 'arb-proj-'));
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
db = openDb(':memory:');
manager = new PtyManager(db, sessionsDir);
svc = new DiscoveryService({ db, ptyManager: manager, projectsDir, sessionsDir });
});
afterEach(() => {
svc.stop();
rmSync(projectsDir, { recursive: true, force: true });
rmSync(sessionsDir, { recursive: true, force: true });
});
it('session morte (pas dentrée registre) → resumable, non attachable', async () => {
writeJsonl(projectsDir, '/home/u/dead', 'dead-sid');
await svc.refresh();
const s = svc.list().find((x) => x.id === 'dead-sid');
expect(s).toMatchObject({ source: 'discovered', live: false, resumable: true, attachable: false, cwd: '/home/u/dead' });
expect(svc.getDiscovered('dead-sid')?.cwd).toBe('/home/u/dead');
});
it('session vivante (registre + pid vivant) → live, non resumable, registryStatus exposé', async () => {
writeJsonl(projectsDir, '/home/u/live', 'live-sid');
const ps = readProcStart(process.pid) as string;
writeFileSync(
join(sessionsDir, `${process.pid}.json`),
JSON.stringify({ pid: process.pid, procStart: ps, sessionId: 'live-sid', cwd: '/home/u/live', status: 'waiting', waitingFor: 'permission prompt' }),
);
await svc.refresh();
const s = svc.list().find((x) => x.id === 'live-sid');
expect(s).toMatchObject({ live: true, resumable: false, attachable: false, registryStatus: 'waiting', pid: process.pid });
});
it('exclut une session déjà connue dArboretum (managée / historique)', async () => {
writeJsonl(projectsDir, '/home/u/managed', 'managed-sid');
db.prepare('INSERT INTO sessions (id, cwd, command, created_at, claude_session_id) VALUES (?, ?, ?, ?, ?)').run(
'uuid-1',
'/home/u/managed',
'claude',
new Date().toISOString(),
'managed-sid',
);
await svc.refresh();
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
});
it('marque hidden une session masquée mais la garde résoluble (resume/fork)', async () => {
writeJsonl(projectsDir, '/home/u/old', 'old-sid');
hideSession(db, 'old-sid');
await svc.refresh();
const s = svc.list().find((x) => x.id === 'old-sid');
expect(s?.hidden).toBe(true);
// toujours connue : la reprise/le fork doivent rester possibles
expect(svc.getDiscovered('old-sid')?.cwd).toBe('/home/u/old');
// ré-affichage
unhideSession(db, 'old-sid');
await svc.refresh();
expect(svc.list().find((x) => x.id === 'old-sid')?.hidden).toBe(false);
});
it('hideSession est idempotent et listHiddenSessionIds reflète létat', () => {
hideSession(db, 'a');
hideSession(db, 'a');
hideSession(db, 'b');
expect(listHiddenSessionIds(db)).toEqual(new Set(['a', 'b']));
unhideSession(db, 'a');
expect(listHiddenSessionIds(db)).toEqual(new Set(['b']));
});
it('émet discovery_update sur changement uniquement', async () => {
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
const seen: SessionSummary[] = [];
svc.on('discovery_update', (s) => seen.push(s));
await svc.refresh();
expect(seen.map((s) => s.id)).toContain('sid-x');
seen.length = 0;
await svc.refresh(); // rien n'a changé → aucune ré-émission
expect(seen).toHaveLength(0);
});
});
describe('mergeSessions', () => {
const mk = (id: string, claudeSessionId: string | null, source: 'managed' | 'discovered'): SessionSummary => ({
id,
cwd: '/x',
command: 'claude',
title: null,
status: 'exited',
live: false,
createdAt: '',
endedAt: null,
exitCode: null,
clients: 0,
source,
claudeSessionId,
pid: null,
resumable: false,
attachable: false,
registryStatus: null,
});
it('dédoublonne par claudeSessionId, managé prioritaire', () => {
const managed = [mk('uuid', 'sid-1', 'managed')];
const discovered = [mk('sid-1', 'sid-1', 'discovered'), mk('sid-2', 'sid-2', 'discovered')];
expect(mergeSessions(managed, discovered).map((s) => s.id)).toEqual(['uuid', 'sid-2']);
});
});