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.
135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
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 d’entré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 d’Arboretum (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']);
|
||
});
|
||
});
|